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:
parent
189448ebdb
commit
6974ac39e8
2 changed files with 87 additions and 33 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue