stremio/apps/master-api/src/stremio/routes.ts
Jos Vooges | STH 6974ac39e8 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>
2026-08-25 05:03:24 +02:00

592 lines
18 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { validateAddonToken } from "../auth/routes";
import { PlaybackService } from "../playback/service";
import { MetadataService } from "../metadata/service";
import type { Config } from "../config";
const ID_PREFIX = "mc";
const CATALOG_PAGE = 100;
const SEARCH_SKIP_EXTRAS = [
{ name: "search", isRequired: false },
{ name: "skip", isRequired: false },
];
const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
const MANIFEST = {
id: "com.mediacluster.library",
version: "1.3.0",
name: "Media Cluster",
description: "Private distributed media library",
catalogs: [
{ type: "movie", id: "movies", name: "Films", extra: SEARCH_SKIP_EXTRAS },
{ type: "series", id: "series", name: "Series", extra: SEARCH_SKIP_EXTRAS },
{ type: "movie", id: "recent-movies", name: "Recent toegevoegd", 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 },
],
// 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", "mc", "sth"],
};
/** Prefer IMDb IDs (Cinemeta-compatible). Else mcmovie-/mcseries- without extra colons. */
function movieId(imdbId: string | null | undefined, uuid: string): string {
if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId;
return `${ID_PREFIX}movie-${uuid}`;
}
function seriesId(imdbId: string | null | undefined, uuid: string): string {
if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId;
return `${ID_PREFIX}series-${uuid}`;
}
/** 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}-`), "");
}
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) {
const playback = new PlaybackService(config);
const metadata = new MetadataService(config.TMDB_API_KEY);
app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/stremio/")) return;
reply.header("Access-Control-Allow-Origin", "*");
if (request.method === "OPTIONS") {
reply
.header("Access-Control-Allow-Methods", "GET, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.status(204)
.send();
}
});
app.get("/stremio/:token/manifest.json", async (request, reply) => {
const { token } = request.params as { token: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
return MANIFEST;
});
// Stremio catalog with extras: /catalog/movie/movies/search=matrix.json
app.get("/stremio/:token/catalog/:type/:id/*", async (request, reply) => {
const { token, type, id } = request.params as {
token: string;
type: string;
id: string;
"*": string;
};
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
const extra = (request.params as { "*": string })["*"] ?? "";
const { search, skip } = parseCatalogExtra(extra);
if (type === "movie") {
return { metas: await getMovieCatalog(id, config, search, skip) };
}
if (type === "series") {
return { metas: await getSeriesCatalog(id, config, search, skip) };
}
return { metas: [] };
});
app.get("/stremio/:token/catalog/:type/:id.json", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
if (type === "movie") {
return { metas: await getMovieCatalog(id, config) };
}
if (type === "series") {
return { metas: await getSeriesCatalog(id, config) };
}
return { metas: [] };
});
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, request.log);
return { meta: meta ?? { id, type: "series", name: "Unknown", videos: [] } };
}
return { meta: { id, type, name: "Unknown" } };
});
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 };
});
// Optional fallback if something still hits Master /play — redirect to node (no proxy).
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);
request.log.info(
{ mediaFileId, streamUrl: session.streamUrl },
"playback delegate to node"
);
return reply.code(302).redirect(session.streamUrl);
} catch (err) {
const message = err instanceof Error ? err.message : "Playback unavailable";
request.log.error({ err, mediaFileId }, "playback create failed");
return reply.status(503).send({ error: message });
}
},
});
}
function parseCatalogExtra(extra: string): { search?: string; skip: number } {
const decoded = decodeURIComponent(extra.replace(/\.json$/, ""));
const parts = decoded.split("&");
let search: string | undefined;
let skip = 0;
for (const part of parts) {
const [key, ...rest] = part.split("=");
const value = rest.join("=");
if (key === "search" && value) search = value;
if (key === "skip") {
const n = parseInt(value, 10);
if (!Number.isNaN(n) && n > 0) skip = n;
}
}
return { search, skip };
}
async function getMovieCatalog(
catalogId: string,
config: Config,
search?: string,
skip = 0
) {
const where: Record<string, unknown> = {};
if (config.HIDE_UNAVAILABLE_TITLES) {
where.mediaFiles = { some: { available: true } };
}
if (search) {
where.title = { contains: search, mode: "insensitive" };
}
let movies = await prisma.movie.findMany({
where,
orderBy: [{ title: "asc" }, { year: "asc" }],
skip,
take: CATALOG_PAGE,
include: { mediaFiles: { where: { available: true } } },
});
if (catalogId === "recent-movies" && !search) {
movies = await prisma.movie.findMany({
where: { mediaFiles: { some: { available: true } } },
orderBy: { createdAt: "desc" },
skip,
take: CATALOG_PAGE,
include: { mediaFiles: { where: { available: true } } },
});
} else if (catalogId === "4k-movies") {
movies = await prisma.movie.findMany({
where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
mediaFiles: {
some: {
available: true,
OR: [
{ resolution: { contains: "2160" } },
{ resolution: { contains: "4K", mode: "insensitive" } },
],
},
},
},
orderBy: [{ title: "asc" }, { year: "asc" }],
skip,
take: CATALOG_PAGE,
include: { mediaFiles: { where: { available: true } } },
});
}
return movies
.filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES)
.map((m) => ({
id: movieId(m.imdbId, m.id),
type: "movie" as const,
name: m.title,
poster: m.posterUrl ?? undefined,
background: m.backdropUrl ?? undefined,
description: m.overview ?? undefined,
releaseInfo: m.year ? String(m.year) : undefined,
genres: m.genres,
}));
}
async function getSeriesCatalog(
catalogId: string,
config: Config,
search?: string,
skip = 0
) {
const availableFilter = {
seasons: {
some: {
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
},
};
let series = await prisma.series.findMany({
where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
...(config.HIDE_UNAVAILABLE_TITLES ? availableFilter : {}),
},
orderBy: [{ title: "asc" }, { year: "asc" }],
skip,
take: CATALOG_PAGE,
});
if (catalogId === "recent-series" && !search) {
series = await prisma.series.findMany({
where: availableFilter,
orderBy: { createdAt: "desc" },
skip,
take: CATALOG_PAGE,
});
}
return series.map((s) => ({
id: seriesId(s.imdbId, s.id),
type: "series" as const,
name: s.title,
poster: s.posterUrl ?? undefined,
background: s.backdropUrl ?? undefined,
description: s.overview ?? undefined,
releaseInfo: s.year ? String(s.year) : undefined,
genres: s.genres,
}));
}
async function getMovieMeta(id: string) {
const movie = await prisma.movie.findFirst({
where: {
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }],
},
});
if (!movie) return null;
return {
id: movieId(movie.imdbId, movie.id),
type: "movie" as const,
name: movie.title,
poster: movie.posterUrl ?? undefined,
background: movie.backdropUrl ?? undefined,
description: movie.overview ?? undefined,
releaseInfo: movie.year ? String(movie.year) : undefined,
runtime: movie.runtime ? `${movie.runtime} min` : undefined,
genres: movie.genres,
imdbRating: undefined,
};
}
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") }],
},
select: {
id: true,
title: true,
year: true,
overview: true,
posterUrl: true,
backdropUrl: true,
genres: true,
imdbId: true,
seasons: {
orderBy: { seasonNumber: "asc" },
select: {
episodes: {
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
mediaFiles: {
where: { available: true },
take: 1,
select: { id: true },
},
},
},
},
},
},
});
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);
const sid = seriesId(series.imdbId, series.id);
let episodes = series.seasons.flatMap((season) =>
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
);
if (episodes.length === 0) {
const fallback = await prisma.series.findFirst({
where: { id: series.id },
select: {
seasons: {
orderBy: { seasonNumber: "asc" },
select: {
episodes: {
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
mediaFiles: { take: 1, select: { id: true } },
},
},
},
},
},
});
episodes =
fallback?.seasons.flatMap((season) =>
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
) ?? [];
}
const stillByEpisodeId = new Map<string, string>();
let logo: string | undefined;
try {
const art = await prisma.series.findFirst({
where: { id: series.id },
select: {
logoUrl: true,
seasons: {
select: {
episodes: { select: { id: true, stillUrl: true } },
},
},
},
});
logo = art?.logoUrl ?? undefined;
for (const season of art?.seasons ?? []) {
for (const ep of season.episodes) {
if (ep.stillUrl) stillByEpisodeId.set(ep.id, ep.stillUrl);
}
}
} catch {
// optional columns
}
const videos = episodes.map((ep) => ({
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
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,
thumbnail: stillByEpisodeId.get(ep.id),
// 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,
name: series.title,
poster: series.posterUrl ?? undefined,
background: series.backdropUrl ?? undefined,
logo,
description: series.overview ?? undefined,
releaseInfo: series.year ? `${series.year}-` : undefined,
genres: series.genres,
videos,
behaviorHints: videos[0]
? { defaultVideoId: videos[0].id, hasScheduledVideos: false }
: undefined,
};
}
async function getStreams(
type: string,
id: string,
userId: string,
playback: PlaybackService,
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
) {
if (type === "movie") {
const movie = await prisma.movie.findFirst({
where: { OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }] },
include: {
mediaFiles: {
where: { available: true },
include: { node: true },
},
},
});
if (!movie) return [];
const streams = [];
for (const file of movie.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
// Master only creates the session; Stremio streams directly from the node.
const session = await playback.createSession(file.id, userId);
log?.info?.(
{ mediaFileId: file.id, streamUrl: session.streamUrl },
"stream delegated to node"
);
streams.push({
name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name),
url: session.streamUrl,
behaviorHints: {
notWebReady: true,
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
}
return streams;
}
if (type === "series") {
const match = id.match(/^(.+):(\d+):(\d+)$/);
if (!match) return [];
const [, seriesKey, seasonStr, episodeStr] = match;
const seasonNumber = parseInt(seasonStr, 10);
const episodeNumber = parseInt(episodeStr, 10);
const series = await prisma.series.findFirst({
where: {
OR: [{ imdbId: seriesKey }, { id: unwrapLocalId(seriesKey, "series") }],
},
});
if (!series) return [];
const episode = await prisma.episode.findUnique({
where: {
seriesId_seasonNumber_episodeNumber: {
seriesId: series.id,
seasonNumber,
episodeNumber,
},
},
include: {
mediaFiles: {
where: { available: true },
include: { node: true },
},
},
});
if (!episode) return [];
const streams = [];
for (const file of episode.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
const session = await playback.createSession(file.id, userId);
log?.info?.(
{ mediaFileId: file.id, streamUrl: session.streamUrl },
"stream delegated to node"
);
streams.push({
name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name),
url: session.streamUrl,
behaviorHints: {
notWebReady: true,
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
}
return streams;
}
return [];
}