Stremio drops videos without ISO-8601 released; also prefer IMDb IDs and mcseries- ids without extra colons. Co-authored-by: Cursor <cursoragent@cursor.com>
592 lines
18 KiB
TypeScript
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 [];
|
|
}
|