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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 05:03:24 +02:00
parent 189448ebdb
commit 6974ac39e8
2 changed files with 87 additions and 33 deletions

View file

@ -29,6 +29,7 @@ type TmdbMeta = {
posterUrl?: string; posterUrl?: string;
backdropUrl?: string; backdropUrl?: string;
logoUrl?: string; logoUrl?: string;
imdbId?: string;
}; };
type TmdbEpisodeMeta = { type TmdbEpisodeMeta = {
@ -299,6 +300,11 @@ export class MetadataService {
year: tmdb.year, year: tmdb.year,
}, },
}); });
if (tmdb.imdbId) {
await prisma.series
.update({ where: { id: series.id }, data: { imdbId: tmdb.imdbId } })
.catch(() => undefined);
}
if (tmdb.logoUrl) { if (tmdb.logoUrl) {
await prisma.series await prisma.series
.update({ where: { id: series.id }, data: { logoUrl: tmdb.logoUrl } }) .update({ where: { id: series.id }, data: { logoUrl: tmdb.logoUrl } })
@ -395,6 +401,7 @@ export class MetadataService {
overview: tmdb.overview ?? series.overview, overview: tmdb.overview ?? series.overview,
title: tmdb.title || series.title, title: tmdb.title || series.title,
year: tmdb.year ?? series.year, year: tmdb.year ?? series.year,
...(tmdb.imdbId && !series.imdbId ? { imdbId: tmdb.imdbId } : {}),
}, },
}); });
if (tmdb.logoUrl) { if (tmdb.logoUrl) {
@ -773,6 +780,7 @@ export class MetadataService {
backdrop_path?: string; backdrop_path?: string;
}; };
const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId); const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId);
const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId);
const meta: TmdbMeta = { const meta: TmdbMeta = {
id: item.id, id: item.id,
title: item.name || "Onbekend", title: item.name || "Onbekend",
@ -785,11 +793,24 @@ export class MetadataService {
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
: undefined, : undefined,
logoUrl, logoUrl,
imdbId,
}; };
this.seriesCache.set(tmdbId, meta); this.seriesCache.set(tmdbId, meta);
return meta; return meta;
} }
private async fetchTmdbSeriesImdbId(tmdbId: number): Promise<string | undefined> {
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<string | undefined> { private async fetchTmdbSeriesLogo(tmdbId: number): Promise<string | undefined> {
if (!this.tmdbApiKey) return undefined; if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -840,6 +861,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl, posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl, backdropUrl: tmdb.backdropUrl,
year: tmdb.year, year: tmdb.year,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
}, },
}).then(async (row) => { }).then(async (row) => {
if (tmdb.logoUrl) { if (tmdb.logoUrl) {

View file

@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
const MANIFEST = { const MANIFEST = {
id: "com.mediacluster.library", id: "com.mediacluster.library",
version: "1.2.0", version: "1.3.0",
name: "Media Cluster", name: "Media Cluster",
description: "Private distributed media library", description: "Private distributed media library",
catalogs: [ catalogs: [
@ -28,24 +28,49 @@ const MANIFEST = {
{ type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA }, { type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA },
{ type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS }, { 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"], 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 { 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 { 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 { function unwrapLocalId(id: string, kind: "movie" | "series"): string {
return id return id
.replace(new RegExp(`^${ID_PREFIX}${kind}-`), "")
.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) { export async function registerStremioRoutes(app: FastifyInstance, config: Config) {
@ -113,31 +138,32 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return { metas: [] }; return { metas: [] };
}); });
app.get("/stremio/:token/meta/:type/:id.json", async (request, reply) => { app.get("/stremio/:token/meta/:type/*", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string }; const { token, type } = request.params as { token: string; type: string; "*": string };
const userId = await validateAddonToken(token); const userId = await validateAddonToken(token);
if (!userId) { if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
const id = paramId((request.params as { "*": string })["*"] ?? "");
if (type === "movie") { if (type === "movie") {
const meta = await getMovieMeta(id); const meta = await getMovieMeta(id);
return { meta: meta ?? { id, type: "movie", name: "Unknown" } }; return { meta: meta ?? { id, type: "movie", name: "Unknown" } };
} }
if (type === "series") { if (type === "series") {
const meta = await getSeriesMeta(id, metadata); const meta = await getSeriesMeta(id, metadata, request.log);
return { meta: meta ?? { id, type: "series", name: "Unknown" } }; return { meta: meta ?? { id, type: "series", name: "Unknown", videos: [] } };
} }
return { meta: { id, type, name: "Unknown" } }; return { meta: { id, type, name: "Unknown" } };
}); });
app.get("/stremio/:token/stream/:type/:id.json", async (request, reply) => { app.get("/stremio/:token/stream/:type/*", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string }; const { token, type } = request.params as { token: string; type: string; "*": string };
const userId = await validateAddonToken(token); const userId = await validateAddonToken(token);
if (!userId) { if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" }); 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); const streams = await getStreams(type, id, userId, playback, request.log);
return { streams }; return { streams };
}); });
@ -317,8 +343,12 @@ async function getMovieMeta(id: string) {
}; };
} }
async function getSeriesMeta(id: string, metadata: MetadataService) { async function getSeriesMeta(
// Core listing query: NEVER select stillUrl/logoUrl (optional columns broke meta before). 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({ const series = await prisma.series.findFirst({
where: { where: {
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], 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.ensureSeriesArtwork(series.id).catch(() => undefined);
void metadata.enrichSeriesEpisodes(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<string, string>(); const stillByEpisodeId = new Map<string, string>();
let logo: string | undefined; let logo: string | undefined;
try { try {
@ -406,9 +438,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
logoUrl: true, logoUrl: true,
seasons: { seasons: {
select: { select: {
episodes: { episodes: { select: { id: true, stillUrl: true } },
select: { id: true, stillUrl: true },
},
}, },
}, },
}, },
@ -420,22 +450,28 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
} }
} }
} catch { } catch {
// columns may not exist yet // optional columns
} }
const videos = episodes.map((ep) => ({ const videos = episodes.map((ep) => ({
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
title: title:
ep.title && !/^episode\s+\d+$/i.test(ep.title) ep.title && !/^episode\s+\d+$/i.test(ep.title.trim())
? ep.title ? ep.title
: `Episode ${ep.episodeNumber}`, : `Episode ${ep.episodeNumber}`,
season: ep.seasonNumber, season: ep.seasonNumber,
episode: ep.episodeNumber, episode: ep.episodeNumber,
overview: ep.overview ?? undefined, overview: ep.overview || undefined,
thumbnail: stillByEpisodeId.get(ep.id), 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 { return {
id: sid, id: sid,
type: "series" as const, type: "series" as const,
@ -444,19 +480,15 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
background: series.backdropUrl ?? undefined, background: series.backdropUrl ?? undefined,
logo, logo,
description: series.overview ?? undefined, description: series.overview ?? undefined,
releaseInfo: series.year ? String(series.year) : undefined, releaseInfo: series.year ? `${series.year}-` : undefined,
genres: series.genres, genres: series.genres,
videos, 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( async function getStreams(
type: string, type: string,
id: string, id: string,