Implement staff display (#1397)

This commit is contained in:
Zoe Roux 2026-03-28 12:41:49 +01:00 committed by GitHub
commit 08529e0d55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 377 additions and 32 deletions

View File

@ -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);

View File

@ -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);
};

View File

@ -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-王浩信",
},
],
},

View File

@ -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" }),

View File

@ -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" })),
}),
),
);

View File

@ -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" });

View File

@ -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",

View File

@ -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";

38
front/src/models/staff.ts Normal file
View File

@ -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<typeof Character>;
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<typeof Staff>;
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<typeof Role>;

View File

@ -71,12 +71,14 @@ export const IconButton = <AsProps = PressableProps>({
as,
className,
iconClassName,
disabled,
...asProps
}: {
as?: ComponentType<AsProps>;
icon: Icon;
iconClassName?: string;
className?: string;
disabled?: boolean;
} & AsProps) => {
const Container = as ?? PressableFeedback;
@ -90,7 +92,13 @@ export const IconButton = <AsProps = PressableProps>({
)}
{...(asProps as AsProps)}
>
<Icon icon={icon} className={iconClassName} />
<Icon
icon={icon}
className={cn(
disabled && "fill-slate-400 dark:fill-slate-600",
iconClassName,
)}
/>
</Container>
);
};

View File

@ -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<number>;
numLines: Breakpoint<number>;
gap: Breakpoint<number>;
};
export const InfiniteGrid = <Data,>({
query,
layout,
Render,
Loader,
Empty,
Header,
Footer,
getItemKey,
}: {
query: QueryIdentifier<Data>;
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 = (
<View className="flex-row items-center">
<IconButton
icon={ArrowBack}
onPress={() => setPageIndex((x) => x - 1)}
disabled={pageIndex <= 0}
{...tooltip(t("misc.prev-page"))}
/>
<IconButton
icon={ArrowForward}
onPress={async () => {
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"))}
/>
</View>
);
const header =
typeof Header === "function" ? (
<Header controls={controls} />
) : (
(Header ?? controls)
);
const footer = typeof Footer === "function" ? <Footer /> : (Footer ?? null);
if (isFetching && pages.length === 0) {
return (
<>
{header}
<View className="flex-row" style={{ gap }}>
{[...Array(numColumns)].map((_, columnIndex) => (
<View key={columnIndex} className="flex-1" style={{ gap }}>
{[...Array(numColumns)].map((__, idx) => (
<Loader key={idx} index={idx} />
))}
</View>
))}
</View>
{footer}
</>
);
}
if (items.length === 0) {
return (
<>
{header}
{Empty}
{footer}
</>
);
}
const columns = items.reduce(
(acc, item, index) => {
acc[index % numColumns].push(item);
return acc;
},
[...Array(numColumns)].map(() => [] as Data[]),
);
return (
<>
{header}
{
<View className="flex-row" style={{ gap }}>
{columns.map((column, columnIndex) => (
<View key={columnIndex} className="flex-1" style={{ gap }}>
{column.map((item, index) => {
const itemIndex = index * numColumns + columnIndex;
return (
<Render
key={getItemKey?.(item, itemIndex) ?? itemIndex}
item={item}
index={itemIndex}
/>
);
})}
</View>
))}
</View>
}
{footer}
</>
);
};

View File

@ -1,3 +1,4 @@
export * from "./fetch";
export * from "./fetch-grid";
export * from "./fetch-infinite";
export * from "./query";

View File

@ -4,6 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQueryState } from "~/utils";
import { HeaderBackground, useScrollNavbar } from "../navbar";
import { Header } from "./header";
import { Staff } from "./staff";
export const MovieDetails = () => {
const [slug] = useQueryState("slug", undefined!);
@ -24,6 +25,7 @@ export const MovieDetails = () => {
slug={slug}
onImageLayout={(e) => setHeight(e.nativeEvent.layout.height)}
/>
<Staff kind="movie" slug={slug} />
</Animated.ScrollView>
</>
);

View File

@ -12,6 +12,7 @@ import { useQueryState } from "~/utils";
import { HeaderBackground, useScrollNavbar } from "../navbar";
import { Header } from "./header";
import { EntryList } from "./season";
import { Staff } from "./staff";
export const SvgWave = (props: ComponentProps<typeof Svg>) => {
// aspect-[width/height]: width/height of the svg
@ -101,7 +102,7 @@ const SerieHeader = ({
}}
Loader={NextUp.Loader}
/>
{/* <Staff slug={slug} /> */}
<Staff kind="serie" slug={slug} />
<SvgWave className="flex-1 shrink-0 fill-card" />
</View>
);

View File

@ -0,0 +1,86 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Role } from "~/models";
import { Container, H2, Image, P, Poster, Skeleton, SubP } from "~/primitives";
import { InfiniteGrid, type QueryIdentifier } from "~/query";
import { EmptyView } from "../empty-view";
const CharacterCard = ({ item }: { item: Role }) => {
const { t } = useTranslation();
return (
<View className="flex-row items-center overflow-hidden rounded-xl bg-card">
<Poster src={item.staff.image} quality="low" className="w-28" />
<View className="flex-1 items-center justify-center py-5">
<P className="text-center font-semibold" numberOfLines={2}>
{item.staff.name}
</P>
<SubP className="mt-1 text-center" numberOfLines={2}>
{item.character
? t("show.staff-as", {
character: item.character.name,
})
: item.kind}
</SubP>
</View>
{item.character && (
<Poster src={item.character.image} quality="low" className="w-28" />
)}
</View>
);
};
CharacterCard.Loader = () => (
<View className="h-32 flex-row overflow-hidden rounded-xl bg-card">
<Skeleton variant="custom" className="h-full w-1/3 rounded-none" />
<View className="flex-1 items-center justify-center px-3">
<Skeleton className="h-5 w-4/5" />
<Skeleton className="mt-2 h-4 w-3/5" />
<Skeleton className="mt-2 h-3 w-2/5" />
</View>
<Skeleton variant="custom" className="h-full w-1/3 rounded-none" />
</View>
);
export const Staff = ({
kind,
slug,
}: {
kind: "serie" | "movie";
slug: string;
}) => {
const { t } = useTranslation();
return (
<Container className="mb-4">
<InfiniteGrid
query={Staff.query(kind, slug)}
layout={{
numColumns: { xs: 1, md: 2, xl: 3 },
numLines: 3,
gap: { xs: 8, lg: 12 },
}}
Header={({ controls }) => (
<View className="mb-3 flex-row items-center justify-between">
<H2>{t("show.staff")}</H2>
{controls}
</View>
)}
Empty={<EmptyView message={t("show.staff-none")} />}
Render={({ item }) => <CharacterCard item={item} />}
Loader={() => <CharacterCard.Loader />}
getItemKey={(item) =>
`${item.staff.id}-${item.kind}-${item.character?.name ?? "none"}`
}
/>
</Container>
);
};
Staff.query = (
kind: "serie" | "movie",
slug: string,
): QueryIdentifier<Role> => ({
path: ["api", `${kind}s`, slug, "staff"],
parser: Role,
infinite: true,
});

View File

@ -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

View File

@ -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,39 @@ 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,
)
if x["name"]
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["personName"]
]