From 6974ac39e87fa2c93b5dd32440e5ada91b4a85f5 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 05:03:24 +0200 Subject: [PATCH] Fix Stremio series episodes per addon SDK: require Video.released, safer IDs, wildcard meta routes. Stremio drops videos without ISO-8601 released; also prefer IMDb IDs and mcseries- ids without extra colons. Co-authored-by: Cursor --- apps/master-api/src/metadata/service.ts | 22 ++++++ apps/master-api/src/stremio/routes.ts | 98 ++++++++++++++++--------- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index f1024dd..da5ad9e 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -29,6 +29,7 @@ type TmdbMeta = { posterUrl?: string; backdropUrl?: string; logoUrl?: string; + imdbId?: string; }; type TmdbEpisodeMeta = { @@ -299,6 +300,11 @@ export class MetadataService { year: tmdb.year, }, }); + if (tmdb.imdbId) { + await prisma.series + .update({ where: { id: series.id }, data: { imdbId: tmdb.imdbId } }) + .catch(() => undefined); + } if (tmdb.logoUrl) { await prisma.series .update({ where: { id: series.id }, data: { logoUrl: tmdb.logoUrl } }) @@ -395,6 +401,7 @@ export class MetadataService { overview: tmdb.overview ?? series.overview, title: tmdb.title || series.title, year: tmdb.year ?? series.year, + ...(tmdb.imdbId && !series.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }); if (tmdb.logoUrl) { @@ -773,6 +780,7 @@ export class MetadataService { backdrop_path?: string; }; const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId); + const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId); const meta: TmdbMeta = { id: item.id, title: item.name || "Onbekend", @@ -785,11 +793,24 @@ export class MetadataService { ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, logoUrl, + imdbId, }; this.seriesCache.set(tmdbId, meta); return meta; } + private async fetchTmdbSeriesImdbId(tmdbId: number): Promise { + if (!this.tmdbApiKey) return undefined; + const params = new URLSearchParams({ api_key: this.tmdbApiKey }); + const res = await fetch( + `https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}` + ); + if (!res.ok) return undefined; + const data = (await res.json()) as { imdb_id?: string | null }; + const id = data.imdb_id?.trim(); + return id && /^tt\d+$/i.test(id) ? id : undefined; + } + private async fetchTmdbSeriesLogo(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ @@ -840,6 +861,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, + ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }).then(async (row) => { if (tmdb.logoUrl) { diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index ef52afb..6bf363b 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }]; const MANIFEST = { id: "com.mediacluster.library", - version: "1.2.0", + version: "1.3.0", name: "Media Cluster", description: "Private distributed media library", catalogs: [ @@ -28,24 +28,49 @@ const MANIFEST = { { type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA }, { type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS }, ], - resources: ["catalog", "meta", "stream"], + // catalog stays a string; meta/stream need idPrefixes for custom IDs (Stremio docs) + resources: [ + "catalog", + { name: "meta", types: ["movie", "series"], idPrefixes: ["tt", "mc", "sth"] }, + { name: "stream", types: ["movie", "series"], idPrefixes: ["tt", "mc", "sth"] }, + ], types: ["movie", "series"], - idPrefixes: ["tt", ID_PREFIX, "sth"], + idPrefixes: ["tt", "mc", "sth"], }; +/** Prefer IMDb IDs (Cinemeta-compatible). Else mcmovie-/mcseries- without extra colons. */ function movieId(imdbId: string | null | undefined, uuid: string): string { - return imdbId ?? `${ID_PREFIX}:movie:${uuid}`; + if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId; + return `${ID_PREFIX}movie-${uuid}`; } function seriesId(imdbId: string | null | undefined, uuid: string): string { - return imdbId ?? `${ID_PREFIX}:series:${uuid}`; + if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId; + return `${ID_PREFIX}series-${uuid}`; } -/** Strip mc:/sth: prefixes used for non-IMDb titles. */ +/** Strip local id prefixes (current + legacy mc:series: / sth:). */ function unwrapLocalId(id: string, kind: "movie" | "series"): string { return id + .replace(new RegExp(`^${ID_PREFIX}${kind}-`), "") .replace(new RegExp(`^${ID_PREFIX}:${kind}:`), "") - .replace(new RegExp(`^sth:${kind}:`), ""); + .replace(new RegExp(`^sth:${kind}:`), "") + .replace(new RegExp(`^sth${kind}-`), ""); +} + +function paramId(raw: string): string { + return decodeURIComponent(raw).replace(/\.json$/i, ""); +} + +/** Stremio requires Video.released (ISO-8601); invalid/missing videos are dropped by the client. */ +function videoReleased(airDate: Date | null | undefined, year: number | null | undefined): string { + if (airDate && !Number.isNaN(airDate.getTime())) { + return airDate.toISOString(); + } + if (year && year > 1900) { + return `${year}-01-01T00:00:00.000Z`; + } + return "2000-01-01T00:00:00.000Z"; } export async function registerStremioRoutes(app: FastifyInstance, config: Config) { @@ -113,31 +138,32 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config return { metas: [] }; }); - app.get("/stremio/:token/meta/:type/:id.json", async (request, reply) => { - const { token, type, id } = request.params as { token: string; type: string; id: string }; + app.get("/stremio/:token/meta/:type/*", async (request, reply) => { + const { token, type } = request.params as { token: string; type: string; "*": string }; const userId = await validateAddonToken(token); if (!userId) { return reply.status(401).send({ error: "Invalid addon token" }); } + const id = paramId((request.params as { "*": string })["*"] ?? ""); if (type === "movie") { const meta = await getMovieMeta(id); return { meta: meta ?? { id, type: "movie", name: "Unknown" } }; } if (type === "series") { - const meta = await getSeriesMeta(id, metadata); - return { meta: meta ?? { id, type: "series", name: "Unknown" } }; + const meta = await getSeriesMeta(id, metadata, request.log); + return { meta: meta ?? { id, type: "series", name: "Unknown", videos: [] } }; } return { meta: { id, type, name: "Unknown" } }; }); - app.get("/stremio/:token/stream/:type/:id.json", async (request, reply) => { - const { token, type, id } = request.params as { token: string; type: string; id: string }; + app.get("/stremio/:token/stream/:type/*", async (request, reply) => { + const { token, type } = request.params as { token: string; type: string; "*": string }; const userId = await validateAddonToken(token); if (!userId) { return reply.status(401).send({ error: "Invalid addon token" }); } - + const id = paramId((request.params as { "*": string })["*"] ?? ""); const streams = await getStreams(type, id, userId, playback, request.log); return { streams }; }); @@ -317,8 +343,12 @@ async function getMovieMeta(id: string) { }; } -async function getSeriesMeta(id: string, metadata: MetadataService) { - // Core listing query: NEVER select stillUrl/logoUrl (optional columns broke meta before). +async function getSeriesMeta( + id: string, + metadata: MetadataService, + log?: { info?: (obj: unknown, msg?: string) => void; warn?: (obj: unknown, msg?: string) => void } +) { + // Core listing: never select optional stillUrl/logoUrl columns here. const series = await prisma.series.findFirst({ where: { OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], @@ -356,7 +386,10 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { }, }); - if (!series) return null; + if (!series) { + log?.warn?.({ id }, "series meta: not found"); + return null; + } void metadata.ensureSeriesArtwork(series.id).catch(() => undefined); void metadata.enrichSeriesEpisodes(series.id).catch(() => undefined); @@ -396,7 +429,6 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { ) ?? []; } - // Optional extras — failure must not clear videos. const stillByEpisodeId = new Map(); let logo: string | undefined; try { @@ -406,9 +438,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { logoUrl: true, seasons: { select: { - episodes: { - select: { id: true, stillUrl: true }, - }, + episodes: { select: { id: true, stillUrl: true } }, }, }, }, @@ -420,22 +450,28 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { } } } catch { - // columns may not exist yet + // optional columns } const videos = episodes.map((ep) => ({ id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, title: - ep.title && !/^episode\s+\d+$/i.test(ep.title) + ep.title && !/^episode\s+\d+$/i.test(ep.title.trim()) ? ep.title : `Episode ${ep.episodeNumber}`, season: ep.seasonNumber, episode: ep.episodeNumber, - overview: ep.overview ?? undefined, + overview: ep.overview || undefined, thumbnail: stillByEpisodeId.get(ep.id), - released: formatMetaDate(ep.airDate), + // REQUIRED by Stremio — without this the client drops the video + released: videoReleased(ep.airDate, series.year), })); + log?.info?.( + { seriesId: series.id, sid, videoCount: videos.length, requestId: id }, + "series meta" + ); + return { id: sid, type: "series" as const, @@ -444,19 +480,15 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { background: series.backdropUrl ?? undefined, logo, description: series.overview ?? undefined, - releaseInfo: series.year ? String(series.year) : undefined, + releaseInfo: series.year ? `${series.year}-` : undefined, genres: series.genres, videos, + behaviorHints: videos[0] + ? { defaultVideoId: videos[0].id, hasScheduledVideos: false } + : undefined, }; } -function formatMetaDate(value: Date | null | undefined): string | undefined { - if (!value) return undefined; - const t = value.getTime(); - if (Number.isNaN(t)) return undefined; - return value.toISOString().slice(0, 10); -} - async function getStreams( type: string, id: string,