Fix Stremio playback: lazy sessions, remove notWebReady, node ACK.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 01:47:54 +02:00
parent 3a7151e3ae
commit 7741500e73
7 changed files with 148 additions and 40 deletions

View file

@ -18,8 +18,8 @@ export default function SettingsPage() {
}); });
const data = await res.json(); const data = await res.json();
setAddonToken(data.token); setAddonToken(data.token);
const base = window.location.origin.replace(":3001", ":3000"); // Addon draait op Master, niet op Admin
setInstallUrl(`${base}/stremio/${data.token}/manifest.json`); setInstallUrl(`https://master.vonas.nl/stremio/${data.token}/manifest.json`);
} }
return ( return (

View file

@ -51,7 +51,7 @@ export class PlaybackService {
}, },
}); });
const pushed = nodeConnectionManager.createPlaybackSession(mediaFile.nodeId, { const acked = await nodeConnectionManager.createPlaybackSessionAcked(mediaFile.nodeId, {
sessionId, sessionId,
tokenHash, tokenHash,
localFileId: mediaFile.localFileId, localFileId: mediaFile.localFileId,
@ -59,7 +59,7 @@ export class PlaybackService {
absoluteExpiresAt: absoluteExpiresAt.toISOString(), absoluteExpiresAt: absoluteExpiresAt.toISOString(),
}); });
if (!pushed) { if (!acked) {
await prisma.playbackSession.update({ await prisma.playbackSession.update({
where: { id: sessionId }, where: { id: sessionId },
data: { status: "REVOKED", revoked: true }, data: { status: "REVOKED", revoked: true },
@ -78,6 +78,12 @@ export class PlaybackService {
}; };
} }
/** Public stream URL for Stremio — session is created only when this URL is opened. */
resolveStreamUrl(addonToken: string, mediaFileId: string): string {
const base = this.config.PUBLIC_URL.replace(/\/$/, "");
return `${base}/stremio/${addonToken}/play/${mediaFileId}`;
}
async revokeSession(sessionId: string): Promise<void> { async revokeSession(sessionId: string): Promise<void> {
const session = await prisma.playbackSession.findUnique({ const session = await prisma.playbackSession.findUnique({
where: { id: sessionId }, where: { id: sessionId },

View file

@ -110,9 +110,30 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
const streams = await getStreams(type, id, userId, playback); const streams = await getStreams(type, id, token, playback);
return { streams }; return { streams };
}); });
// Lazy session: Stremio opens this URL only when actually playing (or prefetching media).
app.route({
method: ["GET", "HEAD"],
url: "/stremio/:token/play/:mediaFileId",
handler: async (request, reply) => {
const { token, mediaFileId } = request.params as { token: string; mediaFileId: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
try {
const session = await playback.createSession(mediaFileId, userId);
return reply.redirect(session.streamUrl);
} catch (err) {
const message = err instanceof Error ? err.message : "Playback unavailable";
return reply.status(503).send({ error: message });
}
},
});
} }
function parseExtraSearch(extra: string): string | undefined { function parseExtraSearch(extra: string): string | undefined {
@ -247,7 +268,12 @@ async function getSeriesMeta(id: string) {
}, },
include: { include: {
seasons: { seasons: {
include: { episodes: { orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }] } }, include: {
episodes: {
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
include: { mediaFiles: { where: { available: true }, take: 1 } },
},
},
orderBy: { seasonNumber: "asc" }, orderBy: { seasonNumber: "asc" },
}, },
}, },
@ -255,14 +281,16 @@ async function getSeriesMeta(id: string) {
if (!series) return null; if (!series) return null;
const videos = series.seasons.flatMap((season) => const videos = series.seasons.flatMap((season) =>
season.episodes.map((ep) => ({ season.episodes
id: `${series.imdbId ?? `sth:series:${series.id}`}:${ep.seasonNumber}:${ep.episodeNumber}`, .filter((ep) => ep.mediaFiles.length > 0)
title: ep.title ?? `Episode ${ep.episodeNumber}`, .map((ep) => ({
season: ep.seasonNumber, id: `${series.imdbId ?? `sth:series:${series.id}`}:${ep.seasonNumber}:${ep.episodeNumber}`,
episode: ep.episodeNumber, title: ep.title ?? `Episode ${ep.episodeNumber}`,
overview: ep.overview ?? undefined, season: ep.seasonNumber,
released: ep.airDate?.toISOString().slice(0, 10), episode: ep.episodeNumber,
})) overview: ep.overview ?? undefined,
released: ep.airDate?.toISOString().slice(0, 10),
}))
); );
return { return {
@ -281,7 +309,7 @@ async function getSeriesMeta(id: string) {
async function getStreams( async function getStreams(
type: string, type: string,
id: string, id: string,
userId: string, addonToken: string,
playback: PlaybackService playback: PlaybackService
) { ) {
if (type === "movie") { if (type === "movie") {
@ -299,17 +327,12 @@ async function getStreams(
const streams = []; const streams = [];
for (const file of movie.mediaFiles) { for (const file of movie.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue; if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try { streams.push({
const session = await playback.createSession(file.id, userId); name: playback.formatStreamLabel(file),
streams.push({ title: playback.formatStreamLabel(file),
name: playback.formatStreamLabel(file), url: playback.resolveStreamUrl(addonToken, file.id),
title: playback.formatStreamLabel(file), behaviorHints: { bingeGroup: `sth-movie-${movie.id}` },
url: session.streamUrl, });
behaviorHints: { bingeGroup: movie.id, notWebReady: true },
});
} catch {
// skip unavailable nodes
}
} }
return streams; return streams;
} }
@ -348,17 +371,12 @@ async function getStreams(
const streams = []; const streams = [];
for (const file of episode.mediaFiles) { for (const file of episode.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue; if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try { streams.push({
const session = await playback.createSession(file.id, userId); name: playback.formatStreamLabel(file),
streams.push({ title: playback.formatStreamLabel(file),
name: playback.formatStreamLabel(file), url: playback.resolveStreamUrl(addonToken, file.id),
title: playback.formatStreamLabel(file), behaviorHints: { bingeGroup: `sth-series-${series.id}-S${seasonNumber}` },
url: session.streamUrl, });
behaviorHints: { bingeGroup: `${series.id}-S${seasonNumber}`, notWebReady: true },
});
} catch {
// skip
}
} }
return streams; return streams;
} }

View file

@ -13,6 +13,7 @@ import type {
HelloPayload, HelloPayload,
LibraryEventPayload, LibraryEventPayload,
PlaybackSessionEndedPayload, PlaybackSessionEndedPayload,
CreatePlaybackSessionAckPayload,
} from "@media-cluster/protocol"; } from "@media-cluster/protocol";
interface NodeConnection { interface NodeConnection {
@ -21,11 +22,17 @@ interface NodeConnection {
lastHeartbeat: Date; lastHeartbeat: Date;
} }
interface PendingAck {
resolve: (ok: boolean) => void;
timer: ReturnType<typeof setTimeout>;
}
class NodeConnectionManager { class NodeConnectionManager {
private connections = new Map<string, NodeConnection>(); private connections = new Map<string, NodeConnection>();
private librarySync: LibrarySyncService | null = null; private librarySync: LibrarySyncService | null = null;
private config: Config | null = null; private config: Config | null = null;
private offlineCheckInterval: ReturnType<typeof setInterval> | null = null; private offlineCheckInterval: ReturnType<typeof setInterval> | null = null;
private pendingSessionAcks = new Map<string, PendingAck>();
init(config: Config): void { init(config: Config): void {
this.config = config; this.config = config;
@ -112,6 +119,9 @@ class NodeConnectionManager {
case "PLAYBACK_SESSION_ENDED": case "PLAYBACK_SESSION_ENDED":
await this.handlePlaybackEnded(message.payload as PlaybackSessionEndedPayload); await this.handlePlaybackEnded(message.payload as PlaybackSessionEndedPayload);
break; break;
case "CREATE_PLAYBACK_SESSION_ACK":
this.handlePlaybackSessionAck(message.payload as CreatePlaybackSessionAckPayload);
break;
default: default:
this.send(socket, createMessage("ERROR", { message: `Unknown type: ${message.type}` })); this.send(socket, createMessage("ERROR", { message: `Unknown type: ${message.type}` }));
} }
@ -230,6 +240,14 @@ class NodeConnectionManager {
this.send(socket, createMessage("FULL_LIBRARY_SYNC_ACK", { accepted: true })); this.send(socket, createMessage("FULL_LIBRARY_SYNC_ACK", { accepted: true }));
} }
private handlePlaybackSessionAck(payload: CreatePlaybackSessionAckPayload): void {
const pending = this.pendingSessionAcks.get(payload.sessionId);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingSessionAcks.delete(payload.sessionId);
pending.resolve(payload.ok);
}
private async handlePlaybackEnded(payload: PlaybackSessionEndedPayload): Promise<void> { private async handlePlaybackEnded(payload: PlaybackSessionEndedPayload): Promise<void> {
await prisma.playbackSession.updateMany({ await prisma.playbackSession.updateMany({
where: { id: payload.sessionId }, where: { id: payload.sessionId },
@ -252,6 +270,36 @@ class NodeConnectionManager {
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload); return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
} }
/** Push session to node and wait for ACK (or timeout). */
async createPlaybackSessionAcked(
nodeId: string,
payload: CreatePlaybackSessionPayload,
timeoutMs = 800
): Promise<boolean> {
if (!this.isOnline(nodeId)) return false;
const acked = new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
// Older nodes may not send ACK — proceed optimistically; node has grace retry.
this.pendingSessionAcks.delete(payload.sessionId);
resolve(true);
}, timeoutMs);
this.pendingSessionAcks.set(payload.sessionId, { resolve, timer });
});
const pushed = this.createPlaybackSession(nodeId, payload);
if (!pushed) {
const pending = this.pendingSessionAcks.get(payload.sessionId);
if (pending) {
clearTimeout(pending.timer);
this.pendingSessionAcks.delete(payload.sessionId);
}
return false;
}
return acked;
}
revokePlaybackSession(nodeId: string, sessionId: string): boolean { revokePlaybackSession(nodeId: string, sessionId: string): boolean {
return this.sendToNode(nodeId, "REVOKE_PLAYBACK_SESSION", { sessionId }); return this.sendToNode(nodeId, "REVOKE_PLAYBACK_SESSION", { sessionId });
} }

View file

@ -229,16 +229,38 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
AbsoluteExpiresAt string `json:"absoluteExpiresAt"` AbsoluteExpiresAt string `json:"absoluteExpiresAt"`
} }
if json.Unmarshal(payload, &p) == nil { if json.Unmarshal(payload, &p) == nil {
expires, _ := time.Parse(time.RFC3339, p.AbsoluteExpiresAt) expires, err := time.Parse(time.RFC3339Nano, p.AbsoluteExpiresAt)
_ = c.streamer.CreateSession(database.PlaybackSession{ if err != nil {
expires, err = time.Parse(time.RFC3339, p.AbsoluteExpiresAt)
}
ok := true
errMsg := ""
if err != nil {
ok = false
errMsg = "invalid absoluteExpiresAt"
log.Printf("Playback session rejected: bad expiry for %s", p.SessionID)
} else if err := c.streamer.CreateSession(database.PlaybackSession{
SessionID: p.SessionID, SessionID: p.SessionID,
TokenHash: p.TokenHash, TokenHash: p.TokenHash,
LocalFileID: p.LocalFileID, LocalFileID: p.LocalFileID,
IdleTimeoutSeconds: p.IdleTimeoutSeconds, IdleTimeoutSeconds: p.IdleTimeoutSeconds,
AbsoluteExpiresAt: expires, AbsoluteExpiresAt: expires,
LastActivity: time.Now().UTC(), LastActivity: time.Now().UTC(),
}); err != nil {
ok = false
errMsg = err.Error()
log.Printf("Playback session create failed: %s (%v)", p.SessionID, err)
} else {
log.Printf("Playback session created: %s", p.SessionID)
}
ack := controlMessage("CREATE_PLAYBACK_SESSION_ACK", map[string]interface{}{
"sessionId": p.SessionID,
"ok": ok,
"error": errMsg,
}) })
log.Printf("Playback session created: %s", p.SessionID) if werr := c.write(ack); werr != nil {
log.Printf("failed to send session ACK: %v", werr)
}
} }
case "REVOKE_PLAYBACK_SESSION": case "REVOKE_PLAYBACK_SESSION":
var p struct { var p struct {

View file

@ -111,6 +111,13 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
tokenHash := hashToken(token) tokenHash := hashToken(token)
sess, err := s.store.GetSessionByTokenHash(tokenHash) sess, err := s.store.GetSessionByTokenHash(tokenHash)
if err != nil {
// Brief grace: Master may have redirected before WS session landed.
for i := 0; i < 20 && err != nil; i++ {
time.Sleep(50 * time.Millisecond)
sess, err = s.store.GetSessionByTokenHash(tokenHash)
}
}
if err != nil { if err != nil {
log.Printf("GET /play/[REDACTED] invalid session from %s", clientIP) log.Printf("GET /play/[REDACTED] invalid session from %s", clientIP)
http.NotFound(w, r) http.NotFound(w, r)

View file

@ -18,6 +18,7 @@ export type ControlMessageType =
| "FULL_LIBRARY_SYNC" | "FULL_LIBRARY_SYNC"
| "FULL_LIBRARY_SYNC_ACK" | "FULL_LIBRARY_SYNC_ACK"
| "CREATE_PLAYBACK_SESSION" | "CREATE_PLAYBACK_SESSION"
| "CREATE_PLAYBACK_SESSION_ACK"
| "REVOKE_PLAYBACK_SESSION" | "REVOKE_PLAYBACK_SESSION"
| "RESCAN" | "RESCAN"
| "CONFIG_UPDATE" | "CONFIG_UPDATE"
@ -68,6 +69,12 @@ export interface PlaybackSessionEndedPayload {
reason: string; reason: string;
} }
export interface CreatePlaybackSessionAckPayload {
sessionId: string;
ok: boolean;
error?: string;
}
export function createMessage<T>( export function createMessage<T>(
type: ControlMessageType, type: ControlMessageType,
payload: T, payload: T,