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 = {}; 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(); 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 []; }