Adapt models (serie & co) for new api

This commit is contained in:
Zoe Roux 2025-06-19 23:48:22 +02:00
parent 43cf343841
commit 6c243b8961
No known key found for this signature in database
19 changed files with 137 additions and 503 deletions

View File

@ -0,0 +1,7 @@
import z from "zod";
export const Entry = z.object({
id: z.string(),
slug: z.string(),
});
export type Entry = z.infer<typeof Entry>;

View File

@ -1,4 +1,4 @@
export * from "./page";
export * from "./utils/page";
export * from "./kyoo-error";
export * from "./resources";
export * from "./traits";

View File

@ -1,26 +0,0 @@
export enum Genre {
Action = "Action",
Adventure = "Adventure",
Animation = "Animation",
Comedy = "Comedy",
Crime = "Crime",
Documentary = "Documentary",
Drama = "Drama",
Family = "Family",
Fantasy = "Fantasy",
History = "History",
Horror = "Horror",
Music = "Music",
Mystery = "Mystery",
Romance = "Romance",
ScienceFiction = "ScienceFiction",
Thriller = "Thriller",
War = "War",
Western = "Western",
Kids = "Kids",
News = "News",
Reality = "Reality",
Soap = "Soap",
Talk = "Talk",
Politics = "Politics",
}

View File

@ -1,16 +0,0 @@
export * from "./account";
export * from "./library-item";
export * from "./news";
export * from "./show";
export * from "./movie";
export * from "./collection";
export * from "./genre";
export * from "./person";
export * from "./studio";
export * from "./episode";
export * from "./season";
export * from "./watch-info";
export * from "./watch-status";
export * from "./watchlist";
export * from "./user";
export * from "./server-info";

View File

@ -1,21 +0,0 @@
import { z } from "zod";
export const MetadataP = z.preprocess(
(x) =>
typeof x === "object" && x ? Object.fromEntries(Object.entries(x).filter(([_, v]) => v)) : x,
z.record(
z.object({
/*
* The ID of the resource on the external provider.
*/
dataId: z.string(),
/*
* The URL of the resource on the external provider.
*/
link: z.string().nullable(),
}),
),
);
export type Metadata = z.infer<typeof MetadataP>;

View File

@ -1,107 +0,0 @@
import { z } from "zod";
import { ImagesP, ResourceP } from "../traits";
import { zdate } from "../utils";
import { BaseEpisodeP } from "./episode.base";
import { Genre } from "./genre";
import { MetadataP } from "./metadata";
import { StudioP } from "./studio";
import { ShowWatchStatusP } from "./watch-status";
/**
* The enum containing show's status.
*/
export enum Status {
Unknown = "Unknown",
Finished = "Finished",
Airing = "Airing",
Planned = "Planned",
}
export const ShowP = ResourceP("show")
.merge(ImagesP)
.extend({
/**
* The title of this show.
*/
name: z.string(),
/**
* A catchphrase for this show.
*/
tagline: z.string().nullable(),
/**
* The list of alternative titles of this show.
*/
aliases: z.array(z.string()),
/**
* The summary of this show.
*/
overview: z.string().nullable(),
/**
* A list of tags that match this movie.
*/
tags: z.array(z.string()),
/**
* Is this show airing, not aired yet or finished?
*/
status: z.nativeEnum(Status),
/**
* How well this item is rated? (from 0 to 100).
*/
rating: z.number().int().gte(0).lte(100),
/**
* The date this show started airing. It can be null if this is unknown.
*/
startAir: zdate().nullable(),
/**
* The date this show finished airing. It can also be null if this is unknown.
*/
endAir: zdate().nullable(),
/**
* The list of genres (themes) this show has.
*/
genres: z.array(z.nativeEnum(Genre)),
/**
* A youtube url for the trailer.
*/
trailer: z.string().optional().nullable(),
/**
* The studio that made this show.
*/
studio: StudioP.optional().nullable(),
/**
* The first episode of this show
*/
firstEpisode: BaseEpisodeP.optional().nullable(),
/**
* The link to metadata providers that this show has.
*/
externalId: MetadataP,
/**
* Metadata of what an user as started/planned to watch.
*/
watchStatus: ShowWatchStatusP.nullable().optional(),
/**
* The number of episodes in this show.
*/
episodesCount: z.number().int().gte(0).optional(),
})
.transform((x) => {
if (!x.thumbnail && x.poster) {
x.thumbnail = { ...x.poster };
if (x.thumbnail) {
x.thumbnail.low = x.thumbnail.high;
x.thumbnail.medium = x.thumbnail.high;
}
}
return x;
})
.transform((x) => ({
href: `/show/${x.slug}`,
playHref: x.firstEpisode ? `/watch/${x.firstEpisode.slug}` : null,
...x,
}));
/**
* A tv serie or an anime.
*/
export type Show = z.infer<typeof ShowP>;

View File

@ -1,14 +0,0 @@
import { z } from "zod";
import { ResourceP } from "../traits";
export const StudioP = ResourceP("studio").extend({
/**
* The name of this studio.
*/
name: z.string(),
});
/**
* A studio that make shows.
*/
export type Studio = z.infer<typeof StudioP>;

View File

@ -1,50 +0,0 @@
import { z } from "zod";
import { zdate } from "../utils";
import { BaseEpisodeP } from "./episode.base";
export enum WatchStatusV {
Completed = "Completed",
Watching = "Watching",
Droped = "Droped",
Planned = "Planned",
}
export const WatchStatusP = z.object({
/**
* The date this item was added to the watchlist (watched or plan to watch by the user).
*/
addedDate: zdate(),
/**
* The date at which this item was played.
*/
playedDate: zdate().nullable(),
/**
* Has the user started watching, is it planned?
*/
status: z.nativeEnum(WatchStatusV),
/**
* Where the player has stopped watching the episode (in seconds).
* Null if the status is not Watching or if the next episode is not started.
*/
watchedTime: z.number().int().gte(0).nullable(),
/**
* Where the player has stopped watching the episode (in percentage between 0 and 100).
* Null if the status is not Watching or if the next episode is not started.
*/
watchedPercent: z.number().int().gte(0).lte(100).nullable(),
});
export type WatchStatus = z.infer<typeof WatchStatusP>;
export const ShowWatchStatusP = WatchStatusP.and(
z.object({
/**
* The number of episodes the user has not seen.
*/
unseenEpisodesCount: z.number().int().gte(0),
/**
* The next episode to watch
*/
nextEpisode: BaseEpisodeP.nullable(),
}),
);
export type ShowWatchStatus = z.infer<typeof ShowWatchStatusP>;

62
front/src/models/serie.ts Normal file
View File

@ -0,0 +1,62 @@
import { z } from "zod";
import { Entry } from "./entry";
import { Studio } from "./studio";
import { Genre } from "./utils/genre";
import { Image } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
export const Serie = z
.object({
id: z.string(),
slug: z.string(),
name: z.string(),
original: z.object({
name: z.string(),
latinName: z.string().nullable(),
language: z.string(),
}),
tagline: z.string().nullable(),
aliases: z.array(z.string()),
tags: z.array(z.string()),
description: z.string().nullable(),
status: z.enum(["unknown", "finished", "airing", "planned"]),
rating: z.number().int().gte(0).lte(100).nullable(),
startAir: zdate().nullable(),
endAir: zdate().nullable(),
genres: z.array(Genre),
runtime: z.number().nullable(),
externalId: Metadata,
entriesCount: z.number().int(),
availableCount: z.number().int(),
poster: Image.nullable(),
thumbnail: Image.nullable(),
banner: Image.nullable(),
logo: Image.nullable(),
trailerUrl: z.string().optional().nullable(),
createdAt: zdate(),
updatedAt: zdate(),
studios: z.array(Studio).optional(),
firstEntry: Entry.optional().nullable(),
nextEntry: Entry.optional().nullable(),
watchStatus: z
.object({
status: z.enum(["completed", "watching", "rewatching", "dropped", "planned"]),
score: z.number().int().gte(0).lte(100).nullable(),
startedAt: zdate().nullable(),
completedAt: zdate().nullable(),
seenCount: z.number().int().gte(0),
})
.nullable(),
})
.transform((x) => ({
...x,
href: `/serie/${x.slug}`,
playHref: x.firstEntry ? `/watch/${x.firstEntry.slug}` : null,
}));
export type Serie = z.infer<typeof Serie>;

View File

@ -0,0 +1,15 @@
import { z } from "zod";
import { Image } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
export const Studio = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
logo: Image.nullable(),
externalId: Metadata,
createdAt: zdate(),
updatedAt: zdate(),
});
export type Studio = z.infer<typeof Studio>;

View File

@ -1,37 +0,0 @@
import { z } from "zod";
export const Img = z.object({
source: z.string(),
blurhash: z.string(),
low: z.string(),
medium: z.string(),
high: z.string(),
});
export const ImagesP = z.object({
/**
* An url to the poster of this resource. If this resource does not have an image, the link will
* be null. If the kyoo's instance is not capable of handling this kind of image for the specific
* resource, this field won't be present.
*/
poster: Img.nullable(),
/**
* An url to the thumbnail of this resource. If this resource does not have an image, the link
* will be null. If the kyoo's instance is not capable of handling this kind of image for the
* specific resource, this field won't be present.
*/
thumbnail: Img.nullable(),
/**
* An url to the logo of this resource. If this resource does not have an image, the link will be
* null. If the kyoo's instance is not capable of handling this kind of image for the specific
* resource, this field won't be present.
*/
logo: Img.nullable(),
});
/**
* Base traits for items that has image resources.
*/
export type KyooImage = z.infer<typeof Img>;

View File

@ -1,2 +0,0 @@
export * from "./resource";
export * from "./images";

View File

@ -1,25 +0,0 @@
import { z } from "zod";
export const ResourceP = <T extends string>(kind: T) =>
z.object({
/**
* A unique ID for this type of resource. This can't be changed and duplicates are not allowed.
*/
id: z.string(),
/**
* A human-readable identifier that can be used instead of an ID. A slug must be unique for a type
* of resource but it can be changed.
*/
slug: z.string(),
/**
* The type of resource
*/
kind: z.literal(kind),
});
/**
* The base trait used to represent identifiable resources.
*/
export type Resource = z.infer<ReturnType<typeof ResourceP>>;

View File

@ -0,0 +1,27 @@
import z from "zod";
export const Genre = z.enum([
"action",
"adventure",
"animation",
"comedy",
"crime",
"documentary",
"drama",
"family",
"fantasy",
"history",
"horror",
"music",
"mystery",
"romance",
"science-fiction",
"thriller",
"war",
"western",
"kids",
"reality",
"politics",
"soap",
"talk",
]);

View File

@ -0,0 +1,16 @@
import { z } from "zod";
export const Image = z
.object({
id: z.string(),
source: z.string(),
blurhash: z.string(),
})
.transform((x) => ({
...x,
low: `/images/${x.id}?quality=low`,
medium: `/images/${x.id}?quality=medium`,
high: `/images/${x.id}?quality=high`,
}));
export type Image = z.infer<typeof Image>;

View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const Metadata = z.record(
z.object({
dataId: z.string(),
link: z.string().nullable(),
}),
);
export type Metadata = z.infer<typeof Metadata>;

View File

@ -1,38 +1,10 @@
import { z } from "zod";
/**
* A page of resource that contains information about the pagination of resources.
*/
export interface Page<T> {
/**
* The link of the current page.
*
* @format uri
*/
this: string;
/**
* The link of the first page.
*
* @format uri
*/
first: string;
/**
* The link of the next page.
*
* @format uri
*/
next: string | null;
/**
* The number of items in the current page.
*/
count: number;
/**
* The list of items in the page.
*/
items: T[];
}

View File

@ -1,176 +0,0 @@
import type { KyooImage, WatchStatusV } from "@kyoo/models";
import {
GradientImageBackground,
Heading,
Link,
P,
Poster,
PosterBackground,
Skeleton,
imageBorderRadius,
important,
ts,
} from "@kyoo/primitives";
import { useState } from "react";
import { Platform, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemContext } from "../../packages/ui/src/components/context-menus";
import type { Layout } from "../fetch";
import { ItemWatchStatus } from "../ui/browse/grid";
export const ItemList = ({
href,
slug,
type,
name,
subtitle,
thumbnail,
poster,
watchStatus,
unseenEpisodesCount,
...props
}: {
href: string;
slug: string;
type: "movie" | "show" | "collection";
name: string;
subtitle: string | null;
poster: KyooImage | null;
thumbnail: KyooImage | null;
watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null;
}) => {
const { css } = useYoshiki("line");
const [moreOpened, setMoreOpened] = useState(false);
return (
<GradientImageBackground
src={thumbnail}
alt={name}
quality="medium"
as={Link}
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css(
{
alignItems: "center",
justifyContent: "space-evenly",
flexDirection: "row",
height: ItemList.layout.size,
borderRadius: px(imageBorderRadius),
overflow: "hidden",
marginX: ItemList.layout.gap,
child: {
more: {
opacity: 0,
},
},
fover: {
title: {
textDecorationLine: "underline",
},
more: {
opacity: 100,
},
},
},
props,
)}
>
<View
{...css({
width: { xs: "50%", lg: "30%" },
})}
>
<View
{...css({
flexDirection: "row",
justifyContent: "center",
})}
>
<Heading
{...css([
"title",
{
textAlign: "center",
fontSize: rem(2),
letterSpacing: rem(0.002),
fontWeight: "900",
textTransform: "uppercase",
},
])}
>
{name}
</Heading>
{type !== "collection" && (
<ItemContext
type={type}
slug={slug}
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
{
// I dont know why marginLeft gets overwritten by the margin: px(2) so we important
marginLeft: important(ts(2)),
bg: (theme) => theme.darkOverlay,
},
"more",
Platform.OS === "web" && moreOpened && { opacity: important(100) },
])}
/>
)}
</View>
{subtitle && (
<P
{...css({
textAlign: "center",
marginRight: ts(4),
})}
>
{subtitle}
</P>
)}
</View>
<PosterBackground src={poster} alt="" quality="low" layout={{ height: percent(80) }}>
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
</PosterBackground>
</GradientImageBackground>
);
};
ItemList.Loader = (props: object) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
justifyContent: "space-evenly",
flexDirection: "row",
height: ItemList.layout.size,
borderRadius: px(imageBorderRadius),
overflow: "hidden",
bg: (theme) => theme.dark.background,
marginX: ItemList.layout.gap,
},
props,
)}
>
<View
{...css({
width: { xs: "50%", lg: "30%" },
flexDirection: "column",
justifyContent: "center",
})}
>
<Skeleton {...css({ height: rem(2), alignSelf: "center" })} />
<Skeleton {...css({ width: rem(5), alignSelf: "center" })} />
</View>
<Poster.Loader layout={{ height: percent(80) }} />
</View>
);
};
ItemList.layout = { numColumns: 1, size: 300, layout: "vertical", gap: ts(2) } satisfies Layout;