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;
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<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> {
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) {

View file

@ -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<string, string>();
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,