Fix Stremio playback: lazy sessions, remove notWebReady, node ACK.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3a7151e3ae
commit
7741500e73
7 changed files with 148 additions and 40 deletions
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue