From e30c9bf219f3be2b13d9c3c7faa72411a2936514 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 27 Mar 2026 19:50:19 +0100 Subject: [PATCH 1/3] Fix refresh handling for yet-to-be-aired series --- api/src/controllers/seed/refresh.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/seed/refresh.ts b/api/src/controllers/seed/refresh.ts index 19259e9b..4c6d02df 100644 --- a/api/src/controllers/seed/refresh.ts +++ b/api/src/controllers/seed/refresh.ts @@ -7,10 +7,14 @@ export const guessNextRefresh = ( if (show.kind === "movie") { return fromAirDate(show.airDate ?? new Date()); } - const lastAirDate = show.entries + const dates = show.entries .filter((x) => x.airDate) - .map((x) => new Date(x.airDate!)) - .reduce((max, cur) => (cur > max ? cur : max)); + .map((x) => new Date(x.airDate!)); + const after = dates.filter((x) => x.getTime() > Date.now()); + const lastAirDate = + after.length > 0 + ? after.reduce((min, cur) => (cur < min ? cur : min)) + : dates.reduce((max, cur) => (cur > max ? cur : max)); return fromAirDate(lastAirDate); }; From 85813129ce70e29dc5c024798b1ba6ba0bc80390 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 27 Mar 2026 19:50:19 +0100 Subject: [PATCH 2/3] Parse staff from tvdb --- api/src/models/examples/dune-2021.ts | 2 +- api/src/models/utils/external-id.ts | 10 +++---- api/src/models/utils/image.ts | 4 +-- scanner/scanner/providers/composite.py | 2 ++ scanner/scanner/providers/thetvdb.py | 40 +++++++++++++++++++++++--- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts index 7bbeccf8..65d87cd8 100644 --- a/api/src/models/examples/dune-2021.ts +++ b/api/src/models/examples/dune-2021.ts @@ -53,7 +53,7 @@ export const dune: SeedMovie = { imdb: [ { dataId: "tt1160419", - link: "https://www.imdb.com/title/tt1160419", + link: "https://thetvdb.com/people/353535-vincent-wong-ho-shun-王浩信", }, ], }, diff --git a/api/src/models/utils/external-id.ts b/api/src/models/utils/external-id.ts index 2e98dfc5..f44f618f 100644 --- a/api/src/models/utils/external-id.ts +++ b/api/src/models/utils/external-id.ts @@ -7,7 +7,7 @@ export const ExternalId = () => t.Array( t.Object({ dataId: t.String(), - link: t.Nullable(t.String({ format: "uri" })), + link: t.Nullable(t.String({ format: "url" })), label: t.Optional(t.Nullable(t.String())), }), ), @@ -29,7 +29,7 @@ export const EpisodeId = t.Record( }), ), episode: t.Integer(), - link: t.Nullable(t.String({ format: "uri" })), + link: t.Nullable(t.String({ format: "url" })), label: t.Optional(t.Nullable(t.String())), }), ), @@ -42,7 +42,7 @@ export const MovieEpisodeId = t.Record( t.Union([ t.Object({ dataId: t.String(), - link: t.Nullable(t.String({ format: "uri" })), + link: t.Nullable(t.String({ format: "url" })), label: t.Optional(t.Nullable(t.String())), }), t.Object({ @@ -59,7 +59,7 @@ export const MovieEpisodeId = t.Record( }), ), episode: t.Integer(), - link: t.Nullable(t.String({ format: "uri" })), + link: t.Nullable(t.String({ format: "url" })), label: t.Optional(t.Nullable(t.String())), }), ]), @@ -77,7 +77,7 @@ export const SeasonId = t.Record( `, }), season: t.Integer(), - link: t.Nullable(t.String({ format: "uri" })), + link: t.Nullable(t.String({ format: "url" })), }), ), ); diff --git a/api/src/models/utils/image.ts b/api/src/models/utils/image.ts index 6d79a4c3..20c5b2ca 100644 --- a/api/src/models/utils/image.ts +++ b/api/src/models/utils/image.ts @@ -2,9 +2,9 @@ import { t } from "elysia"; export const Image = t.Object({ id: t.String(), - source: t.String({ format: "uri" }), + source: t.String({ format: "url" }), blurhash: t.String(), }); export type Image = typeof Image.static; -export const SeedImage = t.String({ format: "uri" }); +export const SeedImage = t.String({ format: "url" }); diff --git a/scanner/scanner/providers/composite.py b/scanner/scanner/providers/composite.py index 7d215938..ce30b549 100644 --- a/scanner/scanner/providers/composite.py +++ b/scanner/scanner/providers/composite.py @@ -36,6 +36,7 @@ class CompositeProvider(Provider): info = await self._tvdb.get_movie(MetadataId.map_dict(ret.external_id)) if info is None: return ret + ret.staff = info.staff if info.collection is not None: ret.collection = info.collection ret.external_id = MetadataId.merge(ret.external_id, info.external_id) @@ -68,6 +69,7 @@ class CompositeProvider(Provider): ) if info is None: return ret + info.staff = ret.staff info.seasons = ret.seasons info.entries = ret.entries info.extras = ret.extras diff --git a/scanner/scanner/providers/thetvdb.py b/scanner/scanner/providers/thetvdb.py index 80326c4b..65689de1 100644 --- a/scanner/scanner/providers/thetvdb.py +++ b/scanner/scanner/providers/thetvdb.py @@ -17,7 +17,8 @@ from ..models.metadataid import EpisodeId, MetadataId, SeasonId from ..models.movie import Movie, MovieStatus, MovieTranslation, SearchMovie from ..models.season import Season, SeasonTranslation from ..models.serie import SearchSerie, Serie, SerieStatus, SerieTranslation -from ..models.staff import Role +from ..models.staff import Character, Person, Role, Staff +from ..utils import to_slug from .names import ProviderName from .provider import Provider, ProviderError @@ -313,11 +314,10 @@ class TVDB(Provider): if not skip_entries else [], entries=entries, - # TODO: map extra entries in extra instead of entries extras=[], collection=await self._get_collection(ret), studios=[], - staff=[], + staff=self._parse_staff(ret), ) def _pick_image( @@ -812,5 +812,37 @@ class TVDB(Provider): }, collection=await self._get_collection(ret), studios=[], - staff=[], + staff=self._parse_staff(ret), ) + + def _parse_staff(self, show: dict[str, Any]) -> list[Staff]: + if not "characters" in show or not show["characters"]: + return [] + return [ + Staff( + kind=self._roles_map.get(x["peopleType"], Role.OTHER), + character=Character( + name=x["name"], + latin_name=None, + image=img if (img := x["image"]) else None, + ), + staff=Person( + slug=to_slug(x["personName"]), + name=x["personName"], + latin_name=None, + image=img if (img := x["personImgURL"]) else None, + external_id={ + self.name: [ + MetadataId( + data_id=x["peopleId"], + link=x["url"] + if x["url"].startswith("http") + else f"https://thetvdb.com/people/{x['url']}", + ), + ] + }, + ), + ) + for x in show["characters"] + if x["name"] and x["personName"] + ] From 6c3616ed2fc2908fdce70996ae98e6252f2c0916 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 28 Mar 2026 11:18:56 +0100 Subject: [PATCH 3/3] Implement staff display --- api/src/controllers/seed/insert/staff.ts | 18 +-- api/src/models/staff.ts | 14 +- front/public/translations/en.json | 3 +- front/src/models/index.ts | 1 + front/src/models/staff.ts | 38 ++++++ front/src/primitives/icons.tsx | 10 +- front/src/query/fetch-grid.tsx | 163 +++++++++++++++++++++++ front/src/query/index.tsx | 1 + front/src/ui/details/movie.tsx | 2 + front/src/ui/details/serie.tsx | 3 +- front/src/ui/details/staff.tsx | 86 ++++++++++++ scanner/scanner/providers/thetvdb.py | 6 +- 12 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 front/src/models/staff.ts create mode 100644 front/src/query/fetch-grid.tsx create mode 100644 front/src/ui/details/staff.tsx diff --git a/api/src/controllers/seed/insert/staff.ts b/api/src/controllers/seed/insert/staff.ts index 28e331fb..1e930d9a 100644 --- a/api/src/controllers/seed/insert/staff.ts +++ b/api/src/controllers/seed/insert/staff.ts @@ -43,14 +43,16 @@ export const insertStaff = record( staffPk: ret.find((y) => y.slug === x.staff.slug)!.pk, kind: x.kind, order: i, - character: { - ...x.character, - image: enqueueOptImage(imgQueue, { - url: x.character.image, - table: roles, - column: sql`${roles.character}['image']`, - }), - }, + character: x.character + ? { + ...x.character, + image: enqueueOptImage(imgQueue, { + url: x.character.image, + table: roles, + column: sql`${roles.character}['image']`, + }), + } + : null, })); await flushImageQueue(tx, imgQueue, -200); diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index c938c810..5121ea6c 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -35,12 +35,14 @@ export type Staff = typeof Staff.static; export const SeedStaff = t.Composite([ t.Omit(Role, ["character"]), t.Object({ - character: t.Composite([ - t.Omit(Character, ["image"]), - t.Object({ - image: t.Nullable(SeedImage), - }), - ]), + character: t.Nullable( + t.Composite([ + t.Omit(Character, ["image"]), + t.Object({ + image: t.Nullable(SeedImage), + }), + ]), + ), staff: t.Composite([ t.Object({ slug: t.String({ format: "slug" }), diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 7167f49d..bece652d 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -47,7 +47,8 @@ "videosCount": "{{number}} videos", "version": "Version {{number}}", "part": "Part {{number}}", - "videos-map": "Edit video mappings" + "videos-map": "Edit video mappings", + "staff-as":"as {{character}}" }, "videos-map": { "none": "NONE", diff --git a/front/src/models/index.ts b/front/src/models/index.ts index 8b3ec060..1a6c9713 100644 --- a/front/src/models/index.ts +++ b/front/src/models/index.ts @@ -7,6 +7,7 @@ export * from "./search"; export * from "./season"; export * from "./serie"; export * from "./show"; +export * from "./staff"; export * from "./studio"; export * from "./user"; export * from "./utils/genre"; diff --git a/front/src/models/staff.ts b/front/src/models/staff.ts new file mode 100644 index 00000000..22be5dac --- /dev/null +++ b/front/src/models/staff.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; +import { KImage } from "./utils/images"; +import { Metadata } from "./utils/metadata"; +import { zdate } from "./utils/utils"; + +export const Character = z.object({ + name: z.string(), + latinName: z.string().nullable(), + image: KImage.nullable(), +}); +export type Character = z.infer; + +export const Staff = z.object({ + id: z.string(), + slug: z.string(), + name: z.string(), + latinName: z.string().nullable(), + image: KImage.nullable(), + externalId: Metadata, + createdAt: zdate(), + updatedAt: zdate(), +}); +export type Staff = z.infer; + +export const Role = z.object({ + kind: z.enum([ + "actor", + "director", + "writter", + "producer", + "music", + "crew", + "other", + ]), + character: Character.nullable(), + staff: Staff, +}); +export type Role = z.infer; diff --git a/front/src/primitives/icons.tsx b/front/src/primitives/icons.tsx index 861b2ec8..f86be613 100644 --- a/front/src/primitives/icons.tsx +++ b/front/src/primitives/icons.tsx @@ -71,12 +71,14 @@ export const IconButton = ({ as, className, iconClassName, + disabled, ...asProps }: { as?: ComponentType; icon: Icon; iconClassName?: string; className?: string; + disabled?: boolean; } & AsProps) => { const Container = as ?? PressableFeedback; @@ -90,7 +92,13 @@ export const IconButton = ({ )} {...(asProps as AsProps)} > - + ); }; diff --git a/front/src/query/fetch-grid.tsx b/front/src/query/fetch-grid.tsx new file mode 100644 index 00000000..8cc0c6dc --- /dev/null +++ b/front/src/query/fetch-grid.tsx @@ -0,0 +1,163 @@ +import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; +import ArrowForward from "@material-symbols/svg-400/rounded/arrow_forward-fill.svg"; +import { + type ComponentType, + type ReactElement, + useEffect, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { + type Breakpoint, + IconButton, + tooltip, + useBreakpointMap, +} from "~/primitives"; +import { type QueryIdentifier, useInfiniteFetch } from "./query"; + +export type GridLayout = { + numColumns: Breakpoint; + numLines: Breakpoint; + gap: Breakpoint; +}; + +export const InfiniteGrid = ({ + query, + layout, + Render, + Loader, + Empty, + Header, + Footer, + getItemKey, +}: { + query: QueryIdentifier; + layout: GridLayout; + Render: (props: { item: Data; index: number }) => ReactElement | null; + Loader: (props: { index: number }) => ReactElement | null; + Empty?: ReactElement; + Header?: ComponentType<{ controls: ReactElement }> | ReactElement; + Footer?: ComponentType | ReactElement; + getItemKey?: (item: Data, index: number) => string | number; +}): ReactElement | null => { + const { t } = useTranslation(); + const [pageIndex, setPageIndex] = useState(0); + const { numColumns, numLines, gap } = useBreakpointMap(layout); + + query = { + ...query, + params: { ...query.params, limit: numColumns * numLines }, + }; + const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = + useInfiniteFetch(query); + + if (!query.infinite) + console.warn("A non infinite query was passed to an InfiniteGrid."); + + const queryIdentity = JSON.stringify(query); + useEffect(() => { + queryIdentity; + setPageIndex(0); + }, [queryIdentity]); + + const pages = data?.pages ?? []; + const items = pages[pageIndex]?.items ?? []; + + const controls = ( + + setPageIndex((x) => x - 1)} + disabled={pageIndex <= 0} + {...tooltip(t("misc.prev-page"))} + /> + { + if (pageIndex < pages.length - 1) { + setPageIndex((x) => x + 1); + return; + } + if (!hasNextPage || isFetchingNextPage) return; + const res = await fetchNextPage(); + if (!res.isError) setPageIndex((x) => x + 1); + }} + disabled={ + pageIndex === pages.length - 1 && (isFetchingNextPage || !hasNextPage) + } + {...tooltip(t("misc.next-page"))} + /> + + ); + + const header = + typeof Header === "function" ? ( +
+ ) : ( + (Header ?? controls) + ); + + const footer = typeof Footer === "function" ?