mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
Add actor/cast information with person pages and search support
Integrate TMDB credits data into the app: cast carousels on title detail pages, person detail pages with biography and filmography, and person search results in the command palette. Includes new persons/titleCast DB tables, profile image caching, and a nightly credits refresh cron job. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { getMovieCredits, getTvAggregateCredits } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type { CastMember } from "@/lib/types/title";
|
||||
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
|
||||
|
||||
const log = createLogger("credits");
|
||||
|
||||
const NOTABLE_DEPARTMENTS = new Set([
|
||||
"Director",
|
||||
"Writer",
|
||||
"Screenplay",
|
||||
"Creator",
|
||||
"Executive Producer",
|
||||
]);
|
||||
|
||||
function upsertPerson(
|
||||
tmdbId: number,
|
||||
name: string,
|
||||
profilePath: string | null,
|
||||
popularity?: number,
|
||||
): string {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.tmdbId, tmdbId))
|
||||
.get();
|
||||
if (existing) return existing.id;
|
||||
|
||||
const row = db
|
||||
.insert(persons)
|
||||
.values({
|
||||
tmdbId,
|
||||
name,
|
||||
profilePath,
|
||||
popularity: popularity ?? null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
if (row) return row.id;
|
||||
|
||||
// Race condition: another insert beat us
|
||||
const found = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.tmdbId, tmdbId))
|
||||
.get();
|
||||
// biome-ignore lint/style/noNonNullAssertion: guaranteed by onConflictDoNothing + prior existence check
|
||||
return found!.id;
|
||||
}
|
||||
|
||||
function upsertTitleCast(
|
||||
titleId: string,
|
||||
personId: string,
|
||||
character: string | null,
|
||||
department: string,
|
||||
job: string | null,
|
||||
displayOrder: number,
|
||||
episodeCount: number | null,
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character,
|
||||
department,
|
||||
job,
|
||||
displayOrder,
|
||||
episodeCount,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job,
|
||||
displayOrder,
|
||||
episodeCount,
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function refreshCredits(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
if (!title) return;
|
||||
|
||||
log.debug(`Refreshing credits for "${title.title}" (${title.type})`);
|
||||
|
||||
try {
|
||||
if (title.type === "movie") {
|
||||
const credits = await getMovieCredits(title.tmdbId);
|
||||
|
||||
// Top 20 cast
|
||||
const castSlice = credits.cast.slice(0, 20);
|
||||
for (let i = 0; i < castSlice.length; i++) {
|
||||
const c = castSlice[i];
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
c.character,
|
||||
"Acting",
|
||||
null,
|
||||
i,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// Notable crew
|
||||
const seenCrew = new Set<string>();
|
||||
let crewOrder = 100;
|
||||
for (const c of credits.crew) {
|
||||
if (!NOTABLE_DEPARTMENTS.has(c.job)) continue;
|
||||
const key = `${c.id}-${c.job}`;
|
||||
if (seenCrew.has(key)) continue;
|
||||
seenCrew.add(key);
|
||||
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
null,
|
||||
c.department,
|
||||
c.job,
|
||||
crewOrder++,
|
||||
null,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const credits = await getTvAggregateCredits(title.tmdbId);
|
||||
|
||||
// Top 20 cast
|
||||
const castSlice = credits.cast.slice(0, 20);
|
||||
for (let i = 0; i < castSlice.length; i++) {
|
||||
const c = castSlice[i];
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
const character = c.roles?.[0]?.character ?? null;
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
character,
|
||||
"Acting",
|
||||
null,
|
||||
i,
|
||||
c.total_episode_count,
|
||||
);
|
||||
}
|
||||
|
||||
// Notable crew
|
||||
const seenCrew = new Set<string>();
|
||||
let crewOrder = 100;
|
||||
for (const c of credits.crew) {
|
||||
for (const j of c.jobs) {
|
||||
if (!NOTABLE_DEPARTMENTS.has(j.job)) continue;
|
||||
const key = `${c.id}-${j.job}`;
|
||||
if (seenCrew.has(key)) continue;
|
||||
seenCrew.add(key);
|
||||
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
null,
|
||||
c.department,
|
||||
j.job,
|
||||
crewOrder++,
|
||||
j.episode_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Credits refreshed for "${title.title}"`);
|
||||
|
||||
if (imageCacheEnabled()) {
|
||||
cacheProfilePhotos(titleId).catch((err) =>
|
||||
log.debug("Profile photo caching failed:", err),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`Failed to refresh credits for title ${titleId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCastForTitle(titleId: string): CastMember[] {
|
||||
const rows = db
|
||||
.select({
|
||||
id: titleCast.id,
|
||||
personId: titleCast.personId,
|
||||
name: persons.name,
|
||||
character: titleCast.character,
|
||||
department: titleCast.department,
|
||||
job: titleCast.job,
|
||||
displayOrder: titleCast.displayOrder,
|
||||
episodeCount: titleCast.episodeCount,
|
||||
profilePath: persons.profilePath,
|
||||
tmdbId: persons.tmdbId,
|
||||
})
|
||||
.from(titleCast)
|
||||
.innerJoin(persons, eq(titleCast.personId, persons.id))
|
||||
.where(eq(titleCast.titleId, titleId))
|
||||
.orderBy(titleCast.displayOrder)
|
||||
.all();
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
profilePath: tmdbImageUrl(r.profilePath, "w185"),
|
||||
}));
|
||||
}
|
||||
@@ -2,18 +2,31 @@ import { mkdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
persons,
|
||||
seasons,
|
||||
titleCast,
|
||||
titles,
|
||||
} from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("image-cache");
|
||||
|
||||
export type ImageCategory = "posters" | "backdrops" | "stills" | "logos";
|
||||
export type ImageCategory =
|
||||
| "posters"
|
||||
| "backdrops"
|
||||
| "stills"
|
||||
| "logos"
|
||||
| "profiles";
|
||||
|
||||
const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
posters: "w500",
|
||||
backdrops: "w1280",
|
||||
stills: "w1280",
|
||||
logos: "w92",
|
||||
profiles: "w185",
|
||||
};
|
||||
|
||||
const IMAGE_BASE_URL =
|
||||
@@ -218,3 +231,28 @@ export async function cacheProviderLogos(titleId: string) {
|
||||
}
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
export async function cacheProfilePhotos(titleId: string) {
|
||||
const castRows = db
|
||||
.select({ profilePath: persons.profilePath })
|
||||
.from(titleCast)
|
||||
.innerJoin(persons, eq(titleCast.personId, persons.id))
|
||||
.where(eq(titleCast.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of castRows) {
|
||||
if (row.profilePath) {
|
||||
const basename = path.basename(row.profilePath);
|
||||
if (!seen.has(basename) && !(await isImageCached("profiles", basename))) {
|
||||
seen.add(basename);
|
||||
tasks.push(downloadAndCacheImage(row.profilePath, "profiles"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tasks.length > 0) {
|
||||
log.debug(`Caching ${tasks.length} profile photos for title ${titleId}`);
|
||||
}
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type { TmdbVideo } from "@/lib/tmdb/types";
|
||||
import type {
|
||||
AvailabilityOffer,
|
||||
CastMember,
|
||||
Episode,
|
||||
ResolvedTitle,
|
||||
Season,
|
||||
} from "@/lib/types/title";
|
||||
import { refreshAvailability } from "./availability";
|
||||
import { extractAndStoreColors, parseColorPalette } from "./colors";
|
||||
import { getCastForTitle, refreshCredits } from "./credits";
|
||||
import {
|
||||
cacheEpisodeStills,
|
||||
cacheImagesForTitle,
|
||||
@@ -83,6 +85,9 @@ export async function importTitle(
|
||||
extractAndStoreColors(existing.id, show.poster_path).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
),
|
||||
refreshCredits(existing.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
refreshAvailability(existing.id).catch((err) =>
|
||||
@@ -91,6 +96,9 @@ export async function importTitle(
|
||||
refreshRecommendations(existing.id).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(existing.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
}
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(existing.id).catch((err) =>
|
||||
@@ -140,6 +148,9 @@ export async function importTitle(
|
||||
extractAndStoreColors(row.id, movie.poster_path).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
),
|
||||
refreshCredits(row.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
refreshAvailability(row.id).catch((err) =>
|
||||
@@ -151,6 +162,9 @@ export async function importTitle(
|
||||
extractAndStoreColors(row.id, movie.poster_path).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
);
|
||||
refreshCredits(row.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
}
|
||||
refreshTrailer(row.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
@@ -197,6 +211,9 @@ export async function importTitle(
|
||||
extractAndStoreColors(row.id, show.poster_path).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
),
|
||||
refreshCredits(row.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
refreshAvailability(row.id).catch((err) =>
|
||||
@@ -208,6 +225,9 @@ export async function importTitle(
|
||||
extractAndStoreColors(row.id, show.poster_path).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
);
|
||||
refreshCredits(row.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
}
|
||||
refreshTrailer(row.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
@@ -277,6 +297,9 @@ export async function refreshTitle(titleId: string) {
|
||||
refreshTrailer(updated.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(updated.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(updated.id).catch((err) =>
|
||||
log.debug("Image caching failed:", err),
|
||||
@@ -516,6 +539,7 @@ export async function getTitleWithChildren(id: string): Promise<{
|
||||
title: ResolvedTitle;
|
||||
seasons: Season[];
|
||||
availability: AvailabilityOffer[];
|
||||
cast: CastMember[];
|
||||
} | null> {
|
||||
let title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
if (!title) return null;
|
||||
@@ -671,7 +695,9 @@ export async function getTitleWithChildren(id: string): Promise<{
|
||||
trailerVideoKey: title.trailerVideoKey,
|
||||
};
|
||||
|
||||
return { title: resolvedTitle, seasons: titleSeasons, availability };
|
||||
const cast = getCastForTitle(id);
|
||||
|
||||
return { title: resolvedTitle, seasons: titleSeasons, availability, cast };
|
||||
}
|
||||
|
||||
export function pickBestTrailer(videos: TmdbVideo[]): string | null {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { getPersonCombinedCredits, getPersonDetails } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type { PersonCredit, ResolvedPerson } from "@/lib/types/title";
|
||||
|
||||
const log = createLogger("person");
|
||||
|
||||
export async function getOrFetchPerson(
|
||||
personId: string,
|
||||
): Promise<ResolvedPerson | null> {
|
||||
const person = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, personId))
|
||||
.get();
|
||||
if (!person) return null;
|
||||
|
||||
// Shell record — lazily hydrate from TMDB
|
||||
if (!person.lastFetchedAt) {
|
||||
try {
|
||||
const details = await getPersonDetails(person.tmdbId);
|
||||
db.update(persons)
|
||||
.set({
|
||||
name: details.name,
|
||||
biography: details.biography || null,
|
||||
birthday: details.birthday,
|
||||
deathday: details.deathday,
|
||||
placeOfBirth: details.place_of_birth,
|
||||
profilePath: details.profile_path,
|
||||
knownForDepartment: details.known_for_department,
|
||||
popularity: details.popularity,
|
||||
imdbId: details.imdb_id,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(persons.id, personId))
|
||||
.run();
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
tmdbId: person.tmdbId,
|
||||
name: details.name,
|
||||
biography: details.biography || null,
|
||||
birthday: details.birthday,
|
||||
deathday: details.deathday,
|
||||
placeOfBirth: details.place_of_birth,
|
||||
profilePath: tmdbImageUrl(details.profile_path, "w185"),
|
||||
knownForDepartment: details.known_for_department,
|
||||
imdbId: details.imdb_id,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error(`Failed to hydrate person ${personId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
tmdbId: person.tmdbId,
|
||||
name: person.name,
|
||||
biography: person.biography,
|
||||
birthday: person.birthday,
|
||||
deathday: person.deathday,
|
||||
placeOfBirth: person.placeOfBirth,
|
||||
profilePath: tmdbImageUrl(person.profilePath, "w185"),
|
||||
knownForDepartment: person.knownForDepartment,
|
||||
imdbId: person.imdbId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrFetchPersonByTmdbId(
|
||||
tmdbId: number,
|
||||
): Promise<ResolvedPerson | null> {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.tmdbId, tmdbId))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
return getOrFetchPerson(existing.id);
|
||||
}
|
||||
|
||||
// Create from TMDB
|
||||
try {
|
||||
const details = await getPersonDetails(tmdbId);
|
||||
const row = db
|
||||
.insert(persons)
|
||||
.values({
|
||||
tmdbId,
|
||||
name: details.name,
|
||||
biography: details.biography || null,
|
||||
birthday: details.birthday,
|
||||
deathday: details.deathday,
|
||||
placeOfBirth: details.place_of_birth,
|
||||
profilePath: details.profile_path,
|
||||
knownForDepartment: details.known_for_department,
|
||||
popularity: details.popularity,
|
||||
imdbId: details.imdb_id,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const person =
|
||||
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||
if (!person) return null;
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
tmdbId: person.tmdbId,
|
||||
name: person.name,
|
||||
biography: person.biography,
|
||||
birthday: person.birthday,
|
||||
deathday: person.deathday,
|
||||
placeOfBirth: person.placeOfBirth,
|
||||
profilePath: tmdbImageUrl(person.profilePath, "w185"),
|
||||
knownForDepartment: person.knownForDepartment,
|
||||
imdbId: person.imdbId,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error(`Failed to fetch person TMDB ${tmdbId}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalFilmography(personId: string): PersonCredit[] {
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
tmdbId: titles.tmdbId,
|
||||
type: titles.type,
|
||||
title: titles.title,
|
||||
posterPath: titles.posterPath,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
character: titleCast.character,
|
||||
department: titleCast.department,
|
||||
job: titleCast.job,
|
||||
})
|
||||
.from(titleCast)
|
||||
.innerJoin(titles, eq(titleCast.titleId, titles.id))
|
||||
.where(eq(titleCast.personId, personId))
|
||||
.all();
|
||||
|
||||
return rows.map((r) => ({
|
||||
titleId: r.titleId,
|
||||
tmdbId: r.tmdbId,
|
||||
type: r.type as "movie" | "tv",
|
||||
title: r.title,
|
||||
posterPath: tmdbImageUrl(r.posterPath, "w500"),
|
||||
releaseDate: r.releaseDate,
|
||||
firstAirDate: r.firstAirDate,
|
||||
voteAverage: r.voteAverage,
|
||||
character: r.character,
|
||||
department: r.department,
|
||||
job: r.job,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchFullFilmography(
|
||||
personId: string,
|
||||
): Promise<PersonCredit[]> {
|
||||
const person = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, personId))
|
||||
.get();
|
||||
if (!person) return [];
|
||||
|
||||
const credits = await getPersonCombinedCredits(person.tmdbId);
|
||||
|
||||
const results: PersonCredit[] = [];
|
||||
|
||||
for (const c of credits.cast) {
|
||||
const type = c.media_type;
|
||||
if (type !== "movie" && type !== "tv") continue;
|
||||
|
||||
// Create shell title if not in DB
|
||||
const existing = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, c.id))
|
||||
.get();
|
||||
let titleId: string;
|
||||
if (existing) {
|
||||
titleId = existing.id;
|
||||
} else {
|
||||
const row = db
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: c.id,
|
||||
type,
|
||||
title: c.title ?? c.name ?? "Unknown",
|
||||
overview: c.overview,
|
||||
releaseDate: c.release_date,
|
||||
firstAirDate: c.first_air_date,
|
||||
posterPath: c.poster_path,
|
||||
backdropPath: c.backdrop_path,
|
||||
popularity: c.popularity,
|
||||
voteAverage: c.vote_average,
|
||||
voteCount: c.vote_count,
|
||||
lastFetchedAt: null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
if (!row) {
|
||||
const found = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, c.id))
|
||||
.get();
|
||||
if (!found) continue;
|
||||
titleId = found.id;
|
||||
} else {
|
||||
titleId = row.id;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
titleId,
|
||||
tmdbId: c.id,
|
||||
type,
|
||||
title: c.title ?? c.name ?? "Unknown",
|
||||
posterPath: tmdbImageUrl(c.poster_path, "w500"),
|
||||
releaseDate: c.release_date ?? null,
|
||||
firstAirDate: c.first_air_date ?? null,
|
||||
voteAverage: c.vote_average,
|
||||
character: c.character ?? null,
|
||||
department: "Acting",
|
||||
job: null,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user