diff --git a/apps/admin-ui/src/app/dashboard/page.tsx b/apps/admin-ui/src/app/dashboard/page.tsx
index 08ae0e5..12283e8 100644
--- a/apps/admin-ui/src/app/dashboard/page.tsx
+++ b/apps/admin-ui/src/app/dashboard/page.tsx
@@ -17,6 +17,7 @@ interface Dashboard {
availableFiles?: number;
moviesMissingImdb?: number;
moviesWithImdbStreams?: number;
+ moviesMissingTmdb?: number;
seriesMissingImdb?: number;
seriesWithImdb?: number;
scannedFiles?: number;
@@ -53,6 +54,8 @@ export default function DashboardPage() {
const [repairBusy, setRepairBusy] = useState(false);
const [dedupeBusy, setDedupeBusy] = useState(false);
const [imdbBusy, setImdbBusy] = useState(false);
+ const [rematchBusy, setRematchBusy] = useState(false);
+ const [rematchMsg, setRematchMsg] = useState("");
const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" })
@@ -76,7 +79,7 @@ export default function DashboardPage() {
async function repairLibrary() {
if (
!confirm(
- "Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Dubbele file-rijen opruimen\n• Ontbrekende IMDb-IDs backfillen (max ~400 per run)\n\nDuurt meestal 1–3 minuten. Herhaal indien nodig."
+ "Bibliotheek herstellen?\n\n• Duplicaten samenvoegen\n• Dode bestanden verwijderen\n• TMDB opnieuw matchen voor gescande titels\n• IMDb-IDs backfillen\n\nDuurt 1–3 minuten."
)
) {
return;
@@ -94,10 +97,13 @@ export default function DashboardPage() {
return;
}
const imdb = json.imdb ?? {};
+ const rematch = json.rematch ?? {};
const imdbRemaining =
(imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0);
+ const rematchRemaining =
+ (rematch.movies?.remaining ?? 0) + (rematch.series?.remaining ?? 0);
setRepairMsg(
- `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${imdbRemaining > 0 ? ` · nog ${imdbRemaining} IMDb open (herhaal of gebruik backfill-knop)` : ""}. Herlaad daarna de Stremio-addon.`
+ `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · TMDB +${rematch.movies?.matched ?? 0} films, +${rematch.series?.matched ?? 0} series · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${rematchRemaining > 0 ? ` · nog ${rematchRemaining} zonder TMDB` : ""}${imdbRemaining > 0 ? ` · nog ${imdbRemaining} zonder IMDb` : ""}.`
);
load();
} catch (err) {
@@ -133,7 +139,7 @@ export default function DashboardPage() {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ limit: 100 }),
+ body: JSON.stringify({ limit: 50 }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -163,8 +169,11 @@ export default function DashboardPage() {
return;
}
if (remaining >= prevRemaining && updated === 0) {
+ const failed = (movies.failed ?? 0) + (series.failed ?? 0);
setImdbMsg(
- `Gestopt na batch ${batch}: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} zonder IMDb (niet beschikbaar op TMDB)`
+ failed > 0
+ ? `TMDB rate limit of tijdelijke fout (${failed} mislukt in batch ${batch}). Wacht 1 minuut en probeer opnieuw. Nog ${remaining} open.`
+ : `Gestopt na batch ${batch}: nog ${remaining} zonder IMDb op TMDB (vaak nieuwe 2026-titels). Films +${totalMoviesUpdated}, series +${totalSeriesUpdated}.`
);
load();
return;
@@ -185,6 +194,61 @@ export default function DashboardPage() {
}
}
+ async function rematchMetadata() {
+ if (
+ !confirm(
+ "Gescande titels zonder TMDB-metadata opnieuw matchen? Dit haalt posters/metadata op en helpt nieuwe films op de planken te tonen."
+ )
+ ) {
+ return;
+ }
+ setRematchBusy(true);
+ setRematchMsg("Batch 1 bezig…");
+ let batch = 0;
+ let totalMovies = 0;
+ let totalSeries = 0;
+ try {
+ while (batch < 100) {
+ batch += 1;
+ setRematchMsg(`Batch ${batch} bezig…`);
+ const res = await fetch("/api/v1/admin/library/rematch-metadata", {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ limit: 50 }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setRematchMsg(json.error?.message ?? "Mislukt");
+ return;
+ }
+ const checked = (json.movies?.checked ?? 0) + (json.series?.checked ?? 0);
+ totalMovies += json.movies?.matched ?? 0;
+ totalSeries += json.series?.matched ?? 0;
+ const remaining = (json.movies?.remaining ?? 0) + (json.series?.remaining ?? 0);
+ if (remaining === 0) {
+ setRematchMsg(`Klaar: TMDB +${totalMovies} films, +${totalSeries} series`);
+ load();
+ return;
+ }
+ if (checked === 0) {
+ setRematchMsg(`Klaar: +${totalMovies} films, +${totalSeries} series · nog ${remaining} niet gevonden op TMDB`);
+ load();
+ return;
+ }
+ setRematchMsg(
+ `Batch ${batch}: +${json.movies?.matched ?? 0} films, +${json.series?.matched ?? 0} series · nog ${remaining} open…`
+ );
+ }
+ setRematchMsg(`Batchlimiet bereikt: +${totalMovies} films, +${totalSeries} series — druk opnieuw om verder te gaan`);
+ load();
+ } catch (err) {
+ setRematchMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt");
+ } finally {
+ setRematchBusy(false);
+ }
+ }
+
async function dedupe() {
if (
!confirm(
@@ -318,10 +382,21 @@ export default function DashboardPage() {
{dedupeMsg &&
{dedupeMsg}
}
+
+
TMDB-metadata (gescande titels)
+
+ Films met stream maar zonder TMDB: {data.moviesMissingTmdb ?? "?"} — nodig voor posters en
+ planken.
+
+
+ {rematchMsg &&
{rematchMsg}
}
+
IMDb-backfill
- Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
+ Alleen voor titels die al TMDB hebben. Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
{data.seriesMissingImdb ?? "?"}
diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts
index a018f0f..3f9d8ca 100644
--- a/apps/master-api/src/admin/routes.ts
+++ b/apps/master-api/src/admin/routes.ts
@@ -29,6 +29,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableFileCount,
moviesMissingImdb,
moviesWithImdbStreams,
+ moviesMissingTmdb,
seriesMissingImdb,
seriesWithImdb,
] = await Promise.all([
@@ -71,6 +72,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
prisma.movie.count({
where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } },
}),
+ prisma.movie.count({
+ where: { tmdbId: null, mediaFiles: { some: { available: true } } },
+ }),
prisma.series.count({
where: { imdbId: null, tmdbId: { not: null } },
}),
@@ -111,6 +115,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableFiles: availableFileCount,
moviesMissingImdb,
moviesWithImdbStreams,
+ moviesMissingTmdb,
seriesMissingImdb,
seriesWithImdb,
scannedFiles,
@@ -519,8 +524,18 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const limit =
typeof body.limit === "number" && body.limit > 0
? Math.min(Math.floor(body.limit), 500)
+ : 50;
+ const result = await metadata.backfillAllImdbIds({ delayMs: 200, limit });
+ return result;
+ });
+
+ app.post("/api/v1/admin/library/rematch-metadata", { preHandler: requireAdmin }, async (request) => {
+ const body = (request.body ?? {}) as { limit?: number };
+ const limit =
+ typeof body.limit === "number" && body.limit > 0
+ ? Math.min(Math.floor(body.limit), 300)
: 100;
- const result = await metadata.backfillAllImdbIds({ delayMs: 80, limit });
+ const result = await metadata.rematchLibraryMetadata({ delayMs: 120, limit });
return result;
});
diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts
index b2f72aa..bcf2563 100644
--- a/apps/master-api/src/metadata/service.ts
+++ b/apps/master-api/src/metadata/service.ts
@@ -21,6 +21,22 @@ const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300";
const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300";
const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
const TMDB_LANG = "nl-NL";
+
+function sleep(ms: number): Promise
{
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function fetchTmdbJson(url: string, retries = 4): Promise {
+ for (let attempt = 0; attempt < retries; attempt++) {
+ const res = await fetch(url);
+ if (res.status === 429) {
+ await sleep(1000 * (attempt + 1));
+ continue;
+ }
+ return res;
+ }
+ return null;
+}
const METAHUB_LOGO = (imdbId: string) =>
`https://images.metahub.space/logo/medium/${imdbId}/img`;
@@ -885,6 +901,7 @@ export class MetadataService {
seriesMerged: number;
staleFilesRemoved: number;
duplicateFilesRemoved: number;
+ rematch: Awaited>;
imdb: Awaited>;
}> {
const moviesMerged = await this.mergeAllDuplicateMovies();
@@ -895,10 +912,142 @@ export class MetadataService {
const duplicateFilesRemoved = await this.removeDuplicateMediaFiles();
- // Cap IMDb backfill so repair finishes in reasonable time; run again if needed.
- const imdb = await this.backfillAllImdbIds({ delayMs: 80, limit: 400 });
+ const rematch = await this.rematchLibraryMetadata({ delayMs: 100, limit: 200 });
- return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, imdb };
+ // Cap IMDb backfill so repair finishes in reasonable time; run again if needed.
+ const imdb = await this.backfillAllImdbIds({ delayMs: 200, limit: 200 });
+
+ return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, rematch, imdb };
+ }
+
+ /**
+ * Re-match library titles that have streams but no TMDB id (common after scan-only adds).
+ */
+ async rematchLibraryMetadata(options?: {
+ delayMs?: number;
+ limit?: number;
+ }): Promise<{
+ movies: { checked: number; matched: number; remaining: number };
+ series: { checked: number; matched: number; remaining: number };
+ }> {
+ const delayMs = options?.delayMs ?? 120;
+ const limit = options?.limit && options.limit > 0 ? options.limit : 150;
+ if (!this.tmdbApiKey) {
+ const [movieRemaining, seriesRemaining] = await Promise.all([
+ prisma.movie.count({
+ where: { tmdbId: null, mediaFiles: { some: { available: true } } },
+ }),
+ prisma.series.count({
+ where: {
+ tmdbId: null,
+ episodes: { some: { mediaFiles: { some: { available: true } } } },
+ },
+ }),
+ ]);
+ return {
+ movies: { checked: 0, matched: 0, remaining: movieRemaining },
+ series: { checked: 0, matched: 0, remaining: seriesRemaining },
+ };
+ }
+
+ const pendingMovies = await prisma.movie.findMany({
+ where: { tmdbId: null, mediaFiles: { some: { available: true } } },
+ orderBy: { updatedAt: "asc" },
+ take: limit,
+ select: { id: true, title: true, year: true },
+ });
+
+ let moviesMatched = 0;
+ for (const movie of pendingMovies) {
+ const tmdb = await this.searchTmdbMovie(movie.title, movie.year ?? undefined);
+ if (tmdb) {
+ const canonical = await this.upsertMovieFromTmdb(tmdb);
+ if (canonical.id !== movie.id) {
+ await prisma.mediaFile.updateMany({
+ where: { movieId: movie.id },
+ data: { movieId: canonical.id },
+ });
+ await prisma.movie.delete({ where: { id: movie.id } }).catch(() => undefined);
+ }
+ moviesMatched += 1;
+ } else {
+ await prisma.movie
+ .update({ where: { id: movie.id }, data: { updatedAt: new Date() } })
+ .catch(() => undefined);
+ }
+ if (delayMs > 0) await sleep(delayMs);
+ }
+
+ const pendingSeries = await prisma.series.findMany({
+ where: {
+ tmdbId: null,
+ episodes: { some: { mediaFiles: { some: { available: true } } } },
+ },
+ orderBy: { updatedAt: "asc" },
+ take: limit,
+ select: { id: true, title: true },
+ });
+
+ let seriesMatched = 0;
+ for (const series of pendingSeries) {
+ const tmdb = await this.searchTmdbSeries(series.title);
+ if (tmdb) {
+ const canonical = await prisma.series.upsert({
+ where: { tmdbId: tmdb.id },
+ create: {
+ title: tmdb.title,
+ year: tmdb.year,
+ overview: tmdb.overview,
+ posterUrl: tmdb.posterUrl,
+ backdropUrl: tmdb.backdropUrl,
+ tmdbId: tmdb.id,
+ ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
+ },
+ update: {
+ title: tmdb.title,
+ overview: tmdb.overview,
+ posterUrl: tmdb.posterUrl,
+ backdropUrl: tmdb.backdropUrl,
+ year: tmdb.year,
+ ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
+ },
+ });
+ if (canonical.id !== series.id) {
+ await this.mergeSeriesInto(canonical.id, series.id);
+ }
+ seriesMatched += 1;
+ } else {
+ await prisma.series
+ .update({ where: { id: series.id }, data: { updatedAt: new Date() } })
+ .catch(() => undefined);
+ }
+ if (delayMs > 0) await sleep(delayMs);
+ }
+
+ const [movieRemaining, seriesRemaining] = await Promise.all([
+ prisma.movie.count({
+ where: { tmdbId: null, mediaFiles: { some: { available: true } } },
+ }),
+ prisma.series.count({
+ where: {
+ tmdbId: null,
+ episodes: { some: { mediaFiles: { some: { available: true } } } },
+ },
+ }),
+ ]);
+
+ return {
+ movies: {
+ checked: pendingMovies.length,
+ matched: moviesMatched,
+ remaining: movieRemaining,
+ },
+ series: {
+ checked: pendingSeries.length,
+ matched: seriesMatched,
+ remaining: seriesRemaining,
+ },
+ };
}
private movieGroupKey(m: { imdbId: string | null; tmdbId: number | null; title: string; year: number | null }): string {
@@ -1822,10 +1971,10 @@ export class MetadataService {
private async fetchTmdbMovieImdbId(tmdbId: number): Promise {
if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ api_key: this.tmdbApiKey });
- const res = await fetch(
+ const res = await fetchTmdbJson(
`https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}`
);
- if (!res.ok) return undefined;
+ 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;
@@ -1873,10 +2022,10 @@ export class MetadataService {
private async fetchTmdbSeriesImdbId(tmdbId: number): Promise {
if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ api_key: this.tmdbApiKey });
- const res = await fetch(
+ const res = await fetchTmdbJson(
`https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}`
);
- if (!res.ok) return undefined;
+ 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;
diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts
index 04afa44..0c95099 100644
--- a/apps/master-api/src/viewer/service.ts
+++ b/apps/master-api/src/viewer/service.ts
@@ -565,45 +565,45 @@ export class ViewerService {
}
async fourKMovies(take: number) {
- const movies = await prisma.movie.findMany({
+ const files = await prisma.mediaFile.findMany({
where: {
- mediaFiles: {
- some: {
- available: true,
- OR: [
- { resolution: { contains: "2160" } },
- { resolution: { contains: "4K", mode: "insensitive" } },
- { resolution: { contains: "UHD", mode: "insensitive" } },
- ],
- },
- },
+ available: true,
+ movieId: { not: null },
+ OR: [
+ { resolution: { contains: "2160" } },
+ { resolution: { contains: "4K", mode: "insensitive" } },
+ { resolution: { contains: "UHD", mode: "insensitive" } },
+ ],
},
orderBy: { createdAt: "desc" },
- take,
- include: {
- mediaFiles: {
- where: {
- available: true,
- OR: [
- { resolution: { contains: "2160" } },
- { resolution: { contains: "4K", mode: "insensitive" } },
- { resolution: { contains: "UHD", mode: "insensitive" } },
- ],
- },
- orderBy: { createdAt: "desc" },
- take: 1,
- },
- },
+ take: Math.max(take * 3, take),
+ include: { movie: true },
});
- return movies.map((m) => ({
- type: "movie" as const,
- id: m.id,
- title: m.title,
- year: m.year,
- posterUrl: m.posterUrl,
- backdropUrl: m.backdropUrl,
- mediaFileId: m.mediaFiles[0]?.id ?? null,
- }));
+ const seen = new Set();
+ const out: Array<{
+ type: "movie";
+ id: string;
+ title: string;
+ year: number | null;
+ posterUrl: string | null;
+ backdropUrl: string | null;
+ mediaFileId: string | null;
+ }> = [];
+ for (const f of files) {
+ if (!f.movieId || !f.movie || seen.has(f.movieId)) continue;
+ seen.add(f.movieId);
+ out.push({
+ type: "movie",
+ id: f.movie.id,
+ title: f.movie.title,
+ year: f.movie.year,
+ posterUrl: f.movie.posterUrl,
+ backdropUrl: f.movie.backdropUrl,
+ mediaFileId: f.id,
+ });
+ if (out.length >= take) break;
+ }
+ return out;
}
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
@@ -619,66 +619,96 @@ export class ViewerService {
take: number
) {
if (kind === "MOVIE") {
- const where = {
- mediaFiles: { some: { shelfId, available: true } },
- };
- const [total, movies] = await Promise.all([
- prisma.movie.count({ where }),
- prisma.movie.findMany({
- where,
- orderBy: [{ title: "asc" }, { year: "asc" }],
- skip,
- take,
- include: {
- mediaFiles: {
- where: { shelfId, available: true },
- orderBy: { createdAt: "desc" },
- take: 1,
- },
+ const [countRow] = await prisma.$queryRaw>`
+ SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
+ FROM media_files mf
+ WHERE mf.shelf_id = ${shelfId}
+ AND mf.available = true
+ AND mf.movie_id IS NOT NULL
+ `;
+ const total = Number(countRow?.count ?? 0);
+ const idRows = await prisma.$queryRaw>`
+ SELECT mf.movie_id
+ FROM media_files mf
+ WHERE mf.shelf_id = ${shelfId}
+ AND mf.available = true
+ AND mf.movie_id IS NOT NULL
+ GROUP BY mf.movie_id
+ ORDER BY MAX(mf.created_at) DESC
+ OFFSET ${skip}
+ LIMIT ${take}
+ `;
+ const ids = idRows.map((r) => r.movie_id);
+ if (!ids.length) return { total, items: [] };
+
+ const movies = await prisma.movie.findMany({
+ where: { id: { in: ids } },
+ include: {
+ mediaFiles: {
+ where: { shelfId, available: true },
+ orderBy: { createdAt: "desc" },
+ take: 1,
},
- }),
- ]);
+ },
+ });
+ const byId = new Map(movies.map((m) => [m.id, m]));
return {
total,
- items: movies.map((m) => ({
- type: "movie" as const,
- id: m.id,
- title: m.title,
- year: m.year,
- posterUrl: m.posterUrl,
- backdropUrl: m.backdropUrl,
- mediaFileId: m.mediaFiles[0]?.id ?? null,
- })),
+ items: ids
+ .map((id) => byId.get(id))
+ .filter((m): m is NonNullable => !!m)
+ .map((m) => ({
+ type: "movie" as const,
+ id: m.id,
+ title: m.title,
+ year: m.year,
+ posterUrl: m.posterUrl,
+ backdropUrl: m.backdropUrl,
+ mediaFileId: m.mediaFiles[0]?.id ?? null,
+ })),
};
}
- const where = {
- episodes: {
- some: {
- mediaFiles: { some: { shelfId, available: true } },
- },
- },
- };
- const [total, series] = await Promise.all([
- prisma.series.count({ where }),
- prisma.series.findMany({
- where,
- orderBy: [{ title: "asc" }, { year: "asc" }],
- skip,
- take,
- }),
- ]);
+ const [countRow] = await prisma.$queryRaw>`
+ SELECT COUNT(DISTINCT e.series_id)::bigint AS count
+ FROM media_files mf
+ INNER JOIN episodes e ON e.id = mf.episode_id
+ WHERE mf.shelf_id = ${shelfId}
+ AND mf.available = true
+ AND mf.episode_id IS NOT NULL
+ `;
+ const total = Number(countRow?.count ?? 0);
+ const idRows = await prisma.$queryRaw>`
+ SELECT e.series_id
+ FROM media_files mf
+ INNER JOIN episodes e ON e.id = mf.episode_id
+ WHERE mf.shelf_id = ${shelfId}
+ AND mf.available = true
+ AND mf.episode_id IS NOT NULL
+ GROUP BY e.series_id
+ ORDER BY MAX(mf.created_at) DESC
+ OFFSET ${skip}
+ LIMIT ${take}
+ `;
+ const ids = idRows.map((r) => r.series_id);
+ if (!ids.length) return { total, items: [] };
+
+ const series = await prisma.series.findMany({ where: { id: { in: ids } } });
+ const byId = new Map(series.map((s) => [s.id, s]));
return {
total,
- items: series.map((s) => ({
- type: "series" as const,
- id: s.id,
- title: s.title,
- year: s.year,
- posterUrl: s.posterUrl,
- backdropUrl: s.backdropUrl,
- mediaFileId: null as string | null,
- })),
+ items: ids
+ .map((id) => byId.get(id))
+ .filter((s): s is NonNullable => !!s)
+ .map((s) => ({
+ type: "series" as const,
+ id: s.id,
+ title: s.title,
+ year: s.year,
+ posterUrl: s.posterUrl,
+ backdropUrl: s.backdropUrl,
+ mediaFileId: null as string | null,
+ })),
};
}