Add studios (#824)

This commit is contained in:
Zoe Roux 2025-03-03 18:13:17 +01:00 committed by GitHub
commit 250c9c8ff9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 3852 additions and 416 deletions

View File

@ -6,159 +6,168 @@ The many-to-many relation between entries (episodes/movies) & videos is NOT a mi
```mermaid ```mermaid
erDiagram erDiagram
shows { shows {
guid id PK guid id PK
kind kind "serie|movie|collection" kind kind "serie|movie|collection"
string(128) slug UK string(128) slug UK
genre[] genres genre[] genres
int rating "From 0 to 100" int rating "From 0 to 100"
status status "NN" status status "NN"
datetime added_date datetime added_date
date start_air date start_air
date end_air "null for movies" date end_air "null for movies"
datetime next_refresh datetime next_refresh
jsonb external_id jsonb external_id
guid studio_id FK guid studio_id FK
string original_language string original_language
guid collection_id FK guid collection_id FK
} }
show_translations { show_translations {
guid id PK, FK guid id PK, FK
string language PK string language PK
string name "NN" string name "NN"
string tagline string tagline
string[] aliases string[] aliases
string description string description
string[] tags string[] tags
string trailerUrl string trailerUrl
jsonb poster jsonb poster
jsonb banner jsonb banner
jsonb logo jsonb logo
jsonb thumbnail jsonb thumbnail
} }
shows ||--|{ show_translations : has shows ||--|{ show_translations : has
shows |o--|| entries : has shows |o--|| entries : has
shows |o--|| shows : has_collection shows |o--|| shows : has_collection
entries { entries {
guid id PK guid id PK
string(256) slug UK string(256) slug UK
guid show_id FK, UK guid show_id FK, UK
%% Order is absolute number. %% Order is absolute number.
uint order "NN" uint order "NN"
uint season_number UK uint season_number UK
uint episode_number UK "NN" uint episode_number UK "NN"
type type "episode|movie|special|extra" type type "episode|movie|special|extra"
date air_date date air_date
uint runtime uint runtime
jsonb thumbnail jsonb thumbnail
datetime next_refresh datetime next_refresh
jsonb external_id jsonb external_id
} }
entry_translations { entry_translations {
guid id PK, FK guid id PK, FK
string language PK string language PK
string name string name
string description string description
} }
entries ||--|{ entry_translations : has entries ||--|{ entry_translations : has
video { video {
guid id PK guid id PK
string path "NN" string path "NN"
uint rendering "dedup for duplicates part1/2" uint rendering "dedup for duplicates part1/2"
uint part uint part
uint version "max version is preferred rendering" uint version "max version is preferred rendering"
} }
video }|--|{ entries : for video }|--|{ entries : for
seasons { seasons {
guid id PK guid id PK
string(256) slug UK string(256) slug UK
guid show_id FK guid show_id FK
uint season_number "NN" uint season_number "NN"
datetime added_date datetime added_date
date start_air date start_air
date end_air date end_air
datetime next_refresh datetime next_refresh
jsonb external_id jsonb external_id
} }
season_translations { season_translations {
guid id PK,FK guid id PK,FK
string language PK string language PK
string name string name
string description string description
jsonb poster jsonb poster
jsonb banner jsonb banner
jsonb logo jsonb logo
jsonb thumbnail jsonb thumbnail
} }
seasons ||--|{ season_translations : has seasons ||--|{ season_translations : has
seasons ||--o{ entries : has seasons ||--o{ entries : has
shows ||--|{ seasons : has shows ||--|{ seasons : has
watched_shows { users {
guid show_id PK, FK guid id PK
guid user_id PK, FK }
status status "completed|watching|droped|planned"
uint seen_entry_count "NN"
}
shows ||--|{ watched_shows : has
watched_entries { watched_shows {
guid entry_id PK, FK guid show_id PK, FK
guid user_id PK, FK guid user_id PK, FK
uint time "in seconds, null of finished" status status "completed|watching|dropped|planned"
uint progress "NN, from 0 to 100" uint seen_entry_count "NN"
datetime played_date guid next_entry FK
} }
entries ||--|{ watched_entries : has shows ||--|{ watched_shows : has
users ||--|{ watched_shows : has
watched_shows ||--|o entries : next_entry
roles { history {
guid show_id PK, FK int id PK
guid staff_id PK, FK guid entry_id FK
uint order guid user_id FK
type type "actor|director|writer|producer|music|other" uint time "in seconds, null of finished"
jsonb character_image uint progress "NN, from 0 to 100"
} datetime played_date
}
entries ||--|{ history : part_of
users ||--|{ history : has
role_translations { roles {
string language PK guid show_id PK, FK
string character_name guid staff_id PK, FK
} uint order
roles||--o{ role_translations : has type type "actor|director|writer|producer|music|other"
shows ||--|{ roles : has jsonb character_image
}
staff { role_translations {
guid id PK string language PK
string(256) slug UK string character_name
jsonb image }
datetime next_refresh roles||--o{ role_translations : has
jsonb external_id shows ||--|{ roles : has
}
staff_translations { staff {
guid id PK,FK guid id PK
string language PK string(256) slug UK
string name "NN" jsonb image
} datetime next_refresh
staff ||--|{ staff_translations : has jsonb external_id
staff ||--|{ roles : has }
studios { staff_translations {
guid id PK guid id PK,FK
string(128) slug UK string language PK
jsonb logo string name "NN"
datetime next_refresh }
jsonb external_id staff ||--|{ staff_translations : has
} staff ||--|{ roles : has
studio_translations { studios {
guid id PK,FK guid id PK
string language PK string(128) slug UK
string name jsonb logo
} datetime next_refresh
studios ||--|{ studio_translations : has jsonb external_id
shows ||--|{ studios : has }
studio_translations {
guid id PK,FK
string language PK
string name
}
studios ||--|{ studio_translations : has
shows }|--|{ studios : has
``` ```

View File

@ -0,0 +1,38 @@
CREATE TABLE "kyoo"."show_studio_join" (
"show" integer NOT NULL,
"studio" integer NOT NULL,
CONSTRAINT "show_studio_join_show_studio_pk" PRIMARY KEY("show","studio")
);
--> statement-breakpoint
CREATE TABLE "kyoo"."studio_translations" (
"pk" integer NOT NULL,
"language" varchar(255) NOT NULL,
"name" text NOT NULL,
"logo" jsonb,
CONSTRAINT "studio_translations_pk_language_pk" PRIMARY KEY("pk","language")
);
--> statement-breakpoint
CREATE TABLE "kyoo"."studios" (
"pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."studios_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(255) NOT NULL,
"external_id" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
CONSTRAINT "studios_id_unique" UNIQUE("id"),
CONSTRAINT "studios_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "kyoo"."entries" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint
ALTER TABLE "kyoo"."seasons" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint
ALTER TABLE "kyoo"."shows" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint
ALTER TABLE "kyoo"."videos" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_show_shows_pk_fk" FOREIGN KEY ("show") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_studio_studios_pk_fk" FOREIGN KEY ("studio") REFERENCES "kyoo"."studios"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kyoo"."studio_translations" ADD CONSTRAINT "studio_translations_pk_studios_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."studios"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "studio_name_trgm" ON "kyoo"."studio_translations" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "entry_kind" ON "kyoo"."entries" USING hash ("kind");--> statement-breakpoint
CREATE INDEX "entry_order" ON "kyoo"."entries" USING btree ("order");--> statement-breakpoint
CREATE INDEX "entry_name_trgm" ON "kyoo"."entry_translations" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "season_name_trgm" ON "kyoo"."season_translations" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "season_nbr" ON "kyoo"."seasons" USING btree ("season_number");

View File

@ -0,0 +1,20 @@
ALTER TABLE "kyoo"."show_studio_join" RENAME COLUMN "show" TO "show_pk";--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" RENAME COLUMN "studio" TO "studio_pk";--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" RENAME COLUMN "entry" TO "entry_pk";--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" RENAME COLUMN "video" TO "video_pk";--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" DROP CONSTRAINT "show_studio_join_show_shows_pk_fk";
--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" DROP CONSTRAINT "show_studio_join_studio_studios_pk_fk";
--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_join_entry_entries_pk_fk";
--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_join_video_videos_pk_fk";
--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" DROP CONSTRAINT "show_studio_join_show_studio_pk";--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_join_entry_video_pk";--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_show_pk_studio_pk_pk" PRIMARY KEY("show_pk","studio_pk");--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_entry_pk_video_pk_pk" PRIMARY KEY("entry_pk","video_pk");--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_show_pk_shows_pk_fk" FOREIGN KEY ("show_pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_studio_pk_studios_pk_fk" FOREIGN KEY ("studio_pk") REFERENCES "kyoo"."studios"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_entry_pk_entries_pk_fk" FOREIGN KEY ("entry_pk") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_video_pk_videos_pk_fk" FOREIGN KEY ("video_pk") REFERENCES "kyoo"."videos"("pk") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,20 @@
"when": 1740872363604, "when": 1740872363604,
"tag": "0009_collections", "tag": "0009_collections",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1740950531468,
"tag": "0010_studios",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1741014917375,
"tag": "0011_join_rename",
"breakpoints": true
} }
] ]
} }

View File

@ -141,8 +141,8 @@ export const insertEntries = async (
.select( .select(
db db
.select({ .select({
entry: sql<number>`vids.entryPk::integer`.as("entry"), entryPk: sql<number>`vids.entryPk::integer`.as("entry"),
video: sql`${videos.pk}`.as("video"), videoPk: sql`${videos.pk}`.as("video"),
slug: computeVideoSlug( slug: computeVideoSlug(
sql`${show.slug}::text`, sql`${show.slug}::text`,
sql`vids.needRendering::boolean`, sql`vids.needRendering::boolean`,
@ -154,7 +154,7 @@ export const insertEntries = async (
.onConflictDoNothing() .onConflictDoNothing()
.returning({ .returning({
slug: entryVideoJoin.slug, slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entry, entryPk: entryVideoJoin.entryPk,
}); });
return retEntries.map((entry) => ({ return retEntries.map((entry) => ({

View File

@ -0,0 +1,55 @@
import { db } from "~/db";
import { showStudioJoin, studioTranslations, studios } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedStudio } from "~/models/studio";
import { processOptImage } from "../images";
type StudioI = typeof studios.$inferInsert;
type StudioTransI = typeof studioTranslations.$inferInsert;
export const insertStudios = async (seed: SeedStudio[], showPk: number) => {
if (!seed.length) return [];
return await db.transaction(async (tx) => {
const vals: StudioI[] = seed.map((x) => {
const { translations, ...item } = x;
return item;
});
const ret = await tx
.insert(studios)
.values(vals)
.onConflictDoUpdate({
target: studios.slug,
set: conflictUpdateAllExcept(studios, [
"pk",
"id",
"slug",
"createdAt",
]),
})
.returning({ pk: studios.pk, id: studios.id, slug: studios.slug });
const trans: StudioTransI[] = seed.flatMap((x, i) =>
Object.entries(x.translations).map(([lang, tr]) => ({
pk: ret[i].pk,
language: lang,
name: tr.name,
logo: processOptImage(tr.logo),
})),
);
await tx
.insert(studioTranslations)
.values(trans)
.onConflictDoUpdate({
target: [studioTranslations.pk, studioTranslations.language],
set: conflictUpdateAllExcept(studioTranslations, ["pk", "language"]),
});
await tx
.insert(showStudioJoin)
.values(ret.map((studio) => ({ showPk: showPk, studioPk: studio.pk })))
.onConflictDoNothing();
return ret;
});
};

View File

@ -4,6 +4,7 @@ import { getYear } from "~/utils";
import { insertCollection } from "./insert/collection"; import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertShow } from "./insert/shows"; import { insertShow } from "./insert/shows";
import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh"; import { guessNextRefresh } from "./refresh";
export const SeedMovieResponse = t.Object({ export const SeedMovieResponse = t.Object({
@ -18,6 +19,12 @@ export const SeedMovieResponse = t.Object({
slug: t.String({ format: "slug", examples: ["sawano-collection"] }), slug: t.String({ format: "slug", examples: ["sawano-collection"] }),
}), }),
), ),
studios: t.Array(
t.Object({
id: t.String({ format: "uuid" }),
slug: t.String({ format: "slug", examples: ["disney"] }),
}),
),
}); });
export type SeedMovieResponse = typeof SeedMovieResponse.static; export type SeedMovieResponse = typeof SeedMovieResponse.static;
@ -38,7 +45,7 @@ export const seedMovie = async (
seed.slug = `random-${getYear(seed.airDate)}`; seed.slug = `random-${getYear(seed.airDate)}`;
} }
const { translations, videos, collection, ...bMovie } = seed; const { translations, videos, collection, studios, ...bMovie } = seed;
const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date()); const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date());
const col = await insertCollection(collection, { const col = await insertCollection(collection, {
@ -74,11 +81,14 @@ export const seedMovie = async (
}, },
]); ]);
const retStudios = await insertStudios(studios, show.pk);
return { return {
updated: show.updated, updated: show.updated,
id: show.id, id: show.id,
slug: show.slug, slug: show.slug,
videos: entry.videos, videos: entry.videos,
collection: col, collection: col,
studios: retStudios,
}; };
}; };

View File

@ -5,6 +5,7 @@ import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertSeasons } from "./insert/seasons"; import { insertSeasons } from "./insert/seasons";
import { insertShow } from "./insert/shows"; import { insertShow } from "./insert/shows";
import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh"; import { guessNextRefresh } from "./refresh";
export const SeedSerieResponse = t.Object({ export const SeedSerieResponse = t.Object({
@ -45,6 +46,12 @@ export const SeedSerieResponse = t.Object({
}), }),
}), }),
), ),
studios: t.Array(
t.Object({
id: t.String({ format: "uuid" }),
slug: t.String({ format: "slug", examples: ["mappa"] }),
}),
),
}); });
export type SeedSerieResponse = typeof SeedSerieResponse.static; export type SeedSerieResponse = typeof SeedSerieResponse.static;
@ -65,7 +72,15 @@ export const seedSerie = async (
seed.slug = `random-${getYear(seed.startAir)}`; seed.slug = `random-${getYear(seed.startAir)}`;
} }
const { translations, seasons, entries, extras, collection, ...serie } = seed; const {
translations,
seasons,
entries,
extras,
collection,
studios,
...serie
} = seed;
const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); const nextRefresh = guessNextRefresh(serie.startAir ?? new Date());
const col = await insertCollection(collection, { const col = await insertCollection(collection, {
@ -92,6 +107,8 @@ export const seedSerie = async (
(extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })), (extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })),
); );
const retStudios = await insertStudios(studios, show.pk);
return { return {
updated: show.updated, updated: show.updated,
id: show.id, id: show.id,
@ -100,5 +117,6 @@ export const seedSerie = async (
entries: retEntries, entries: retEntries,
extras: retExtras, extras: retExtras,
collection: col, collection: col,
studios: retStudios,
}; };
}; };

View File

@ -1,11 +0,0 @@
import { Elysia, t } from "elysia";
import { Serie } from "~/models/serie";
export const series = new Elysia({ prefix: "/series" })
.model({
serie: Serie,
error: t.Object({}),
})
.get("/:id", () => "hello" as unknown as Serie, {
response: { 200: "serie" },
});

View File

@ -10,6 +10,8 @@ import {
import { KError } from "~/models/error"; import { KError } from "~/models/error";
import { duneCollection } from "~/models/examples"; import { duneCollection } from "~/models/examples";
import { Movie } from "~/models/movie"; import { Movie } from "~/models/movie";
import { Serie } from "~/models/serie";
import { Show } from "~/models/show";
import { import {
AcceptLanguage, AcceptLanguage,
Filter, Filter,
@ -171,6 +173,34 @@ export const collections = new Elysia({
}, },
}, },
) )
.guard({
params: t.Object({
id: t.String({
description: "The id or slug of the collection.",
example: duneCollection.slug,
}),
}),
query: t.Object({
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
preferOriginal: t.Optional(
t.Boolean({
description: desc.preferOriginal,
}),
),
}),
headers: t.Object({
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
})
.get( .get(
"/:id/movies", "/:id/movies",
async ({ async ({
@ -216,32 +246,6 @@ export const collections = new Elysia({
}, },
{ {
detail: { description: "Get all movies in a collection" }, detail: { description: "Get all movies in a collection" },
params: t.Object({
id: t.String({
description: "The id or slug of the collection.",
example: duneCollection.slug,
}),
}),
query: t.Object({
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
preferOriginal: t.Optional(
t.Boolean({
description: desc.preferOriginal,
}),
),
}),
headers: t.Object({
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: { response: {
200: Page(Movie), 200: Page(Movie),
404: { 404: {
@ -297,34 +301,8 @@ export const collections = new Elysia({
}, },
{ {
detail: { description: "Get all series in a collection" }, detail: { description: "Get all series in a collection" },
params: t.Object({
id: t.String({
description: "The id or slug of the collection.",
example: duneCollection.slug,
}),
}),
query: t.Object({
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
preferOriginal: t.Optional(
t.Boolean({
description: desc.preferOriginal,
}),
),
}),
headers: t.Object({
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: { response: {
200: Page(Movie), 200: Page(Serie),
404: { 404: {
...KError, ...KError,
description: "No collection found with the given id or slug.", description: "No collection found with the given id or slug.",
@ -374,34 +352,8 @@ export const collections = new Elysia({
}, },
{ {
detail: { description: "Get all series & movies in a collection" }, detail: { description: "Get all series & movies in a collection" },
params: t.Object({
id: t.String({
description: "The id or slug of the collection.",
example: duneCollection.slug,
}),
}),
query: t.Object({
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
preferOriginal: t.Optional(
t.Boolean({
description: desc.preferOriginal,
}),
),
}),
headers: t.Object({
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: { response: {
200: Page(Movie), 200: Page(Show),
404: { 404: {
...KError, ...KError,
description: "No collection found with the given id or slug.", description: "No collection found with the given id or slug.",

View File

@ -1,7 +1,7 @@
import type { StaticDecode } from "@sinclair/typebox"; import type { StaticDecode } from "@sinclair/typebox";
import { type SQL, and, eq, sql } from "drizzle-orm"; import { type SQL, and, eq, sql } from "drizzle-orm";
import { db } from "~/db"; import { db } from "~/db";
import { showTranslations, shows } from "~/db/schema"; import { showTranslations, shows, studioTranslations } from "~/db/schema";
import { getColumns, sqlarr } from "~/db/utils"; import { getColumns, sqlarr } from "~/db/utils";
import type { MovieStatus } from "~/models/movie"; import type { MovieStatus } from "~/models/movie";
import { SerieStatus } from "~/models/serie"; import { SerieStatus } from "~/models/serie";
@ -12,6 +12,7 @@ import {
Sort, Sort,
isUuid, isUuid,
keysetPaginate, keysetPaginate,
selectTranslationQuery,
sortToSql, sortToSql,
} from "~/models/utils"; } from "~/models/utils";
@ -130,7 +131,7 @@ export async function getShow(
}: { }: {
languages: string[]; languages: string[];
preferOriginal: boolean | undefined; preferOriginal: boolean | undefined;
relations: ("translations" | "videos")[]; relations: ("translations" | "studios" | "videos")[];
filters: SQL | undefined; filters: SQL | undefined;
}, },
) { ) {
@ -141,18 +142,7 @@ export async function getShow(
}, },
where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters), where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters),
with: { with: {
selectedTranslation: { selectedTranslation: selectTranslationQuery(showTranslations, languages),
columns: {
pk: false,
},
where: !languages.includes("*")
? eq(showTranslations.language, sql`any(${sqlarr(languages)})`)
: undefined,
orderBy: [
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
],
limit: 1,
},
originalTranslation: { originalTranslation: {
columns: { columns: {
poster: true, poster: true,
@ -175,6 +165,23 @@ export async function getShow(
}, },
}, },
}), }),
...(relations.includes("studios") && {
studios: {
with: {
studio: {
columns: {
pk: false,
},
with: {
selectedTranslation: selectTranslationQuery(
studioTranslations,
languages,
),
},
},
},
},
}),
}, },
}); });
if (!ret) return null; if (!ret) return null;
@ -184,6 +191,7 @@ export async function getShow(
const show = { const show = {
...ret, ...ret,
...translation, ...translation,
kind: ret.kind as any,
...(ot?.preferOriginal && { ...(ot?.preferOriginal && {
...(ot.poster && { poster: ot.poster }), ...(ot.poster && { poster: ot.poster }),
...(ot.thumbnail && { thumbnail: ot.thumbnail }), ...(ot.thumbnail && { thumbnail: ot.thumbnail }),
@ -197,6 +205,12 @@ export async function getShow(
), ),
), ),
}), }),
...(ret.studios && {
studios: ret.studios.map((x: any) => ({
...x.studio,
...x.studio.selectedTranslation[0],
})),
}),
}; };
return { show, language: translation.language }; return { show, language: translation.language };
} }

View File

@ -65,7 +65,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
preferOriginal: t.Optional( preferOriginal: t.Optional(
t.Boolean({ description: desc.preferOriginal }), t.Boolean({ description: desc.preferOriginal }),
), ),
with: t.Array(t.UnionEnum(["translations", "videos"]), { with: t.Array(t.UnionEnum(["translations", "studios", "videos"]), {
default: [], default: [],
description: "Include related resources in the response.", description: "Include related resources in the response.",
}), }),

View File

@ -65,7 +65,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
preferOriginal: t.Optional( preferOriginal: t.Optional(
t.Boolean({ description: desc.preferOriginal }), t.Boolean({ description: desc.preferOriginal }),
), ),
with: t.Array(t.UnionEnum(["translations"]), { with: t.Array(t.UnionEnum(["translations", "studios"]), {
default: [], default: [],
description: "Include related resources in the response.", description: "Include related resources in the response.",
}), }),

View File

@ -2,10 +2,8 @@ import { and, isNull, sql } from "drizzle-orm";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { db } from "~/db"; import { db } from "~/db";
import { shows } from "~/db/schema"; import { shows } from "~/db/schema";
import { Collection } from "~/models/collections";
import { KError } from "~/models/error"; import { KError } from "~/models/error";
import { Movie } from "~/models/movie"; import { Show } from "~/models/show";
import { Serie } from "~/models/serie";
import { import {
AcceptLanguage, AcceptLanguage,
Filter, Filter,
@ -16,8 +14,6 @@ import {
import { desc } from "~/models/utils/descriptions"; import { desc } from "~/models/utils/descriptions";
import { getShows, showFilters, showSort } from "./logic"; import { getShows, showFilters, showSort } from "./logic";
const Show = t.Union([Movie, Serie, Collection]);
export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] }) export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
.model({ .model({
show: Show, show: Show,

View File

@ -0,0 +1,412 @@
import { and, eq, exists, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { db } from "~/db";
import {
showStudioJoin,
shows,
studioTranslations,
studios,
} from "~/db/schema";
import { getColumns, sqlarr } from "~/db/utils";
import { KError } from "~/models/error";
import { Movie } from "~/models/movie";
import { Serie } from "~/models/serie";
import { Show } from "~/models/show";
import { Studio, StudioTranslation } from "~/models/studio";
import {
AcceptLanguage,
Filter,
Page,
Sort,
createPage,
isUuid,
keysetPaginate,
processLanguages,
selectTranslationQuery,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
import { getShows, showFilters, showSort } from "./shows/logic";
const studioSort = Sort(["slug", "createdAt"], { default: ["slug"] });
export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
.model({
studio: Studio,
"studio-translation": StudioTranslation,
})
.get(
"/:id",
async ({
params: { id },
headers: { "accept-language": languages },
query: { with: relations },
error,
set,
}) => {
const langs = processLanguages(languages);
const ret = await db.query.studios.findFirst({
where: isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id),
with: {
selectedTranslation: selectTranslationQuery(
studioTranslations,
langs,
),
...(relations.includes("translations") && {
translations: {
columns: {
pk: false,
},
},
}),
},
});
if (!ret) {
return error(404, {
status: 404,
message: `No studio with the id or slug: '${id}'`,
});
}
const tr = ret.selectedTranslation[0];
set.headers["content-language"] = tr.language;
return {
...ret,
...tr,
...(ret.translations && {
translations: Object.fromEntries(
ret.translations.map(
({ language, ...translation }) =>
[language, translation] as const,
),
),
}),
};
},
{
detail: {
description: "Get a studio by id or slug",
},
params: t.Object({
id: t.String({
description: "The id or slug of the collection to retrieve.",
example: "mappa",
}),
}),
query: t.Object({
with: t.Array(t.UnionEnum(["translations"]), {
default: [],
description: "Include related resources in the response.",
}),
}),
headers: t.Object({
"accept-language": AcceptLanguage(),
}),
response: {
200: "studio",
404: {
...KError,
description: "No collection found with the given id or slug.",
},
422: KError,
},
},
)
.get(
"random",
async ({ error, redirect }) => {
const [studio] = await db
.select({ slug: studios.slug })
.from(studios)
.orderBy(sql`random()`)
.limit(1);
if (!studio)
return error(404, {
status: 404,
message: "No studios in the database.",
});
return redirect(`/studios/${studio.slug}`);
},
{
detail: {
description: "Get a random studio.",
},
response: {
302: t.Void({
description:
"Redirected to the [/studios/{id}](#tag/studios/GET/studios/{id}) route.",
}),
404: {
...KError,
description: "No studios in the database.",
},
},
},
)
.get(
"",
async ({
query: { limit, after, query, sort },
headers: { "accept-language": languages },
request: { url },
}) => {
const langs = processLanguages(languages);
const transQ = db
.selectDistinctOn([studioTranslations.pk])
.from(studioTranslations)
.orderBy(
studioTranslations.pk,
sql`array_position(${sqlarr(langs)}, ${studioTranslations.language}`,
)
.as("t");
const { pk, ...transCol } = getColumns(transQ);
const items = await db
.select({
...getColumns(studios),
...transCol,
})
.from(studios)
.where(
and(
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ table: studios, after, sort }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${transQ.name})`]
: sortToSql(sort, studios)),
studios.pk,
)
.limit(limit);
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get all studios" },
query: t.Object({
sort: studioSort,
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
}),
headers: t.Object({
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Studio),
422: KError,
},
},
)
.guard({
params: t.Object({
id: t.String({
description: "The id or slug of the studio.",
example: "mappa",
}),
}),
query: t.Object({
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
preferOriginal: t.Optional(
t.Boolean({
description: desc.preferOriginal,
}),
),
}),
headers: t.Object({
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
})
.get(
"/:id/shows",
async ({
params: { id },
query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages },
request: { url },
error,
}) => {
const [studio] = await db
.select({ pk: studios.pk })
.from(studios)
.where(isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id))
.limit(1);
if (!studio) {
return error(404, {
status: 404,
message: `No studios with the id or slug: '${id}'.`,
});
}
const langs = processLanguages(languages);
const items = await getShows({
limit,
after,
query,
sort,
filter: and(
exists(
db
.select()
.from(showStudioJoin)
.where(
and(
eq(showStudioJoin.studioPk, studio.pk),
eq(showStudioJoin.showPk, shows.pk),
),
),
),
filter,
),
languages: langs,
preferOriginal,
});
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get all series & movies made by a studio." },
response: {
200: Page(Show),
404: {
...KError,
description: "No collection found with the given id or slug.",
},
422: KError,
},
},
)
.get(
"/:id/movies",
async ({
params: { id },
query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages },
request: { url },
error,
}) => {
const [studio] = await db
.select({ pk: studios.pk })
.from(studios)
.where(isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id))
.limit(1);
if (!studio) {
return error(404, {
status: 404,
message: `No studios with the id or slug: '${id}'.`,
});
}
const langs = processLanguages(languages);
const items = await getShows({
limit,
after,
query,
sort,
filter: and(
eq(shows.kind, "movie"),
exists(
db
.select()
.from(showStudioJoin)
.where(
and(
eq(showStudioJoin.studioPk, studio.pk),
eq(showStudioJoin.showPk, shows.pk),
),
),
),
filter,
),
languages: langs,
preferOriginal,
});
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get all movies made by a studio." },
response: {
200: Page(Movie),
404: {
...KError,
description: "No collection found with the given id or slug.",
},
422: KError,
},
},
)
.get(
"/:id/series",
async ({
params: { id },
query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages },
request: { url },
error,
}) => {
const [studio] = await db
.select({ pk: studios.pk })
.from(studios)
.where(isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id))
.limit(1);
if (!studio) {
return error(404, {
status: 404,
message: `No studios with the id or slug: '${id}'.`,
});
}
const langs = processLanguages(languages);
const items = await getShows({
limit,
after,
query,
sort,
filter: and(
eq(shows.kind, "serie"),
exists(
db
.select()
.from(showStudioJoin)
.where(
and(
eq(showStudioJoin.studioPk, studio.pk),
eq(showStudioJoin.showPk, shows.pk),
),
),
),
filter,
),
languages: langs,
preferOriginal,
});
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get all series made by a studio." },
response: {
200: Page(Serie),
404: {
...KError,
description: "No collection found with the given id or slug.",
},
422: KError,
},
},
);

View File

@ -42,78 +42,77 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] })
return error(201, oldRet); return error(201, oldRet);
// TODO: this is a huge untested wip // TODO: this is a huge untested wip
// biome-ignore lint/correctness/noUnreachable: leave me alone // const vidsI = db.$with("vidsI").as(
const vidsI = db.$with("vidsI").as( // db.insert(videos).values(body).onConflictDoNothing().returning({
db.insert(videos).values(body).onConflictDoNothing().returning({ // pk: videos.pk,
pk: videos.pk, // id: videos.id,
id: videos.id, // path: videos.path,
path: videos.path, // guess: videos.guess,
guess: videos.guess, // }),
}), // );
); //
// const findEntriesQ = db
const findEntriesQ = db // .select({
.select({ // guess: videos.guess,
guess: videos.guess, // entryPk: entries.pk,
entryPk: entries.pk, // showSlug: shows.slug,
showSlug: shows.slug, // // TODO: handle extras here
// TODO: handle extras here // // guessit can't know if an episode is a special or not. treat specials like a normal episode.
// guessit can't know if an episode is a special or not. treat specials like a normal episode. // kind: sql`
kind: sql` // case when ${entries.kind} = 'movie' then 'movie' else 'episode' end
case when ${entries.kind} = 'movie' then 'movie' else 'episode' end // `.as("kind"),
`.as("kind"), // season: entries.seasonNumber,
season: entries.seasonNumber, // episode: entries.episodeNumber,
episode: entries.episodeNumber, // })
}) // .from(entries)
.from(entries) // .leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk))
.leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk)) // .leftJoin(videos, eq(videos.pk, entryVideoJoin.video))
.leftJoin(videos, eq(videos.pk, entryVideoJoin.video)) // .leftJoin(shows, eq(shows.pk, entries.showPk))
.leftJoin(shows, eq(shows.pk, entries.showPk)) // .as("find_entries");
.as("find_entries"); //
// const hasRenderingQ = db
const hasRenderingQ = db // .select()
.select() // .from(entryVideoJoin)
.from(entryVideoJoin) // .where(eq(entryVideoJoin.entry, findEntriesQ.entryPk));
.where(eq(entryVideoJoin.entry, findEntriesQ.entryPk)); //
// const ret = await db
const ret = await db // .with(vidsI)
.with(vidsI) // .insert(entryVideoJoin)
.insert(entryVideoJoin) // .select(
.select( // db
db // .select({
.select({ // entry: findEntriesQ.entryPk,
entry: findEntriesQ.entryPk, // video: vidsI.pk,
video: vidsI.pk, // slug: computeVideoSlug(
slug: computeVideoSlug( // findEntriesQ.showSlug,
findEntriesQ.showSlug, // sql`exists(${hasRenderingQ})`,
sql`exists(${hasRenderingQ})`, // ),
), // })
}) // .from(vidsI)
.from(vidsI) // .leftJoin(
.leftJoin( // findEntriesQ,
findEntriesQ, // and(
and( // eq(
eq( // sql`${findEntriesQ.guess}->'title'`,
sql`${findEntriesQ.guess}->'title'`, // sql`${vidsI.guess}->'title'`,
sql`${vidsI.guess}->'title'`, // ),
), // // TODO: find if @> with a jsonb created on the fly is
// TODO: find if @> with a jsonb created on the fly is // // better than multiples checks
// better than multiples checks // sql`${vidsI.guess} @> {"kind": }::jsonb`,
sql`${vidsI.guess} @> {"kind": }::jsonb`, // inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`),
inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`), // inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`),
inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`), // inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`),
inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`), // ),
), // ),
), // )
) // .onConflictDoNothing()
.onConflictDoNothing() // .returning({
.returning({ // slug: entryVideoJoin.slug,
slug: entryVideoJoin.slug, // entryPk: entryVideoJoin.entry,
entryPk: entryVideoJoin.entry, // id: vidsI.id,
id: vidsI.id, // path: vidsI.path,
path: vidsI.path, // });
}); // return error(201, ret as any);
return error(201, ret as any);
}, },
{ {
body: t.Array(SeedVideo), body: t.Array(SeedVideo),

View File

@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
import { import {
check, check,
date, date,
index,
integer, integer,
jsonb, jsonb,
primaryKey, primaryKey,
@ -70,11 +71,17 @@ export const entries = schema.table(
createdAt: timestamp({ withTimezone: true, mode: "string" }) createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
}, },
(t) => [ (t) => [
unique().on(t.showPk, t.seasonNumber, t.episodeNumber), unique().on(t.showPk, t.seasonNumber, t.episodeNumber),
check("order_positive", sql`${t.order} >= 0`), check("order_positive", sql`${t.order} >= 0`),
index("entry_kind").using("hash", t.kind),
index("entry_order").on(t.order),
], ],
); );
@ -91,7 +98,10 @@ export const entryTranslations = schema.table(
tagline: text(), tagline: text(),
poster: image(), poster: image(),
}, },
(t) => [primaryKey({ columns: [t.pk, t.language] })], (t) => [
primaryKey({ columns: [t.pk, t.language] }),
index("entry_name_trgm").using("gin", sql`${t.name} gin_trgm_ops`),
],
); );
export const entryRelations = relations(entries, ({ one, many }) => ({ export const entryRelations = relations(entries, ({ one, many }) => ({

View File

@ -1,4 +1,5 @@
export * from "./entries"; export * from "./entries";
export * from "./seasons"; export * from "./seasons";
export * from "./shows"; export * from "./shows";
export * from "./studios";
export * from "./videos"; export * from "./videos";

View File

@ -1,4 +1,4 @@
import { relations } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { import {
date, date,
index, index,
@ -45,11 +45,15 @@ export const seasons = schema.table(
createdAt: timestamp({ withTimezone: true, mode: "string" }) createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
}, },
(t) => [ (t) => [
unique().on(t.showPk, t.seasonNumber), unique().on(t.showPk, t.seasonNumber),
index("show_fk").using("hash", t.showPk), index("show_fk").using("hash", t.showPk),
index("season_nbr").on(t.seasonNumber),
], ],
); );
@ -66,7 +70,10 @@ export const seasonTranslations = schema.table(
thumbnail: image(), thumbnail: image(),
banner: image(), banner: image(),
}, },
(t) => [primaryKey({ columns: [t.pk, t.language] })], (t) => [
primaryKey({ columns: [t.pk, t.language] }),
index("season_name_trgm").using("gin", sql`${t.name} gin_trgm_ops`),
],
); );
export const seasonRelations = relations(seasons, ({ one, many }) => ({ export const seasonRelations = relations(seasons, ({ one, many }) => ({

View File

@ -5,7 +5,6 @@ import {
date, date,
index, index,
integer, integer,
jsonb,
primaryKey, primaryKey,
smallint, smallint,
text, text,
@ -15,7 +14,8 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { entries } from "./entries"; import { entries } from "./entries";
import { seasons } from "./seasons"; import { seasons } from "./seasons";
import { image, language, schema } from "./utils"; import { showStudioJoin } from "./studios";
import { externalid, image, language, schema } from "./utils";
export const showKind = schema.enum("show_kind", [ export const showKind = schema.enum("show_kind", [
"serie", "serie",
@ -54,20 +54,6 @@ export const genres = schema.enum("genres", [
"talk", "talk",
]); ]);
export const externalid = () =>
jsonb()
.$type<
Record<
string,
{
dataId: string;
link: string | null;
}
>
>()
.notNull()
.default({});
export const shows = schema.table( export const shows = schema.table(
"shows", "shows",
{ {
@ -92,6 +78,9 @@ export const shows = schema.table(
createdAt: timestamp({ withTimezone: true, mode: "string" }) createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
}, },
(t) => [ (t) => [
@ -141,6 +130,7 @@ export const showsRelations = relations(shows, ({ many, one }) => ({
}), }),
entries: many(entries, { relationName: "show_entries" }), entries: many(entries, { relationName: "show_entries" }),
seasons: many(seasons, { relationName: "show_seasons" }), seasons: many(seasons, { relationName: "show_seasons" }),
studios: many(showStudioJoin, { relationName: "ssj_show" }),
})); }));
export const showsTrRelations = relations(showTranslations, ({ one }) => ({ export const showsTrRelations = relations(showTranslations, ({ one }) => ({
show: one(shows, { show: one(shows, {

View File

@ -0,0 +1,89 @@
import { relations, sql } from "drizzle-orm";
import {
index,
integer,
primaryKey,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { shows } from "./shows";
import { externalid, image, language, schema } from "./utils";
export const studios = schema.table("studios", {
pk: integer().primaryKey().generatedAlwaysAsIdentity(),
id: uuid().notNull().unique().defaultRandom(),
slug: varchar({ length: 255 }).notNull().unique(),
externalId: externalid(),
createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
});
export const studioTranslations = schema.table(
"studio_translations",
{
pk: integer()
.notNull()
.references(() => studios.pk, { onDelete: "cascade" }),
language: language().notNull(),
name: text().notNull(),
logo: image(),
},
(t) => [
primaryKey({ columns: [t.pk, t.language] }),
index("studio_name_trgm").using("gin", sql`${t.name} gin_trgm_ops`),
],
);
export const showStudioJoin = schema.table(
"show_studio_join",
{
showPk: integer()
.notNull()
.references(() => shows.pk, { onDelete: "cascade" }),
studioPk: integer()
.notNull()
.references(() => studios.pk, { onDelete: "cascade" }),
},
(t) => [primaryKey({ columns: [t.showPk, t.studioPk] })],
);
export const studioRelations = relations(studios, ({ many }) => ({
translations: many(studioTranslations, {
relationName: "studio_translations",
}),
selectedTranslation: many(studioTranslations, {
relationName: "studio_selected_translation",
}),
showsJoin: many(showStudioJoin, { relationName: "ssj_studio" }),
}));
export const studioTrRelations = relations(studioTranslations, ({ one }) => ({
studio: one(studios, {
relationName: "studio_translations",
fields: [studioTranslations.pk],
references: [studios.pk],
}),
selectedTranslation: one(studios, {
relationName: "studio_selected_translation",
fields: [studioTranslations.pk],
references: [studios.pk],
}),
}));
export const ssjRelations = relations(showStudioJoin, ({ one }) => ({
show: one(shows, {
relationName: "ssj_show",
fields: [showStudioJoin.showPk],
references: [shows.pk],
}),
studio: one(studios, {
relationName: "ssj_studio",
fields: [showStudioJoin.studioPk],
references: [studios.pk],
}),
}));

View File

@ -6,3 +6,17 @@ export const language = () => varchar({ length: 255 });
export const image = () => export const image = () =>
jsonb().$type<{ id: string; source: string; blurhash: string }>(); jsonb().$type<{ id: string; source: string; blurhash: string }>();
export const externalid = () =>
jsonb()
.$type<
Record<
string,
{
dataId: string;
link: string | null;
}
>
>()
.notNull()
.default({});

View File

@ -26,6 +26,9 @@ export const videos = schema.table(
createdAt: timestamp({ withTimezone: true, mode: "string" }) createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
}, },
(t) => [ (t) => [
check("part_pos", sql`${t.part} >= 0`), check("part_pos", sql`${t.part} >= 0`),
@ -36,15 +39,15 @@ export const videos = schema.table(
export const entryVideoJoin = schema.table( export const entryVideoJoin = schema.table(
"entry_video_join", "entry_video_join",
{ {
entry: integer() entryPk: integer()
.notNull() .notNull()
.references(() => entries.pk, { onDelete: "cascade" }), .references(() => entries.pk, { onDelete: "cascade" }),
video: integer() videoPk: integer()
.notNull() .notNull()
.references(() => videos.pk, { onDelete: "cascade" }), .references(() => videos.pk, { onDelete: "cascade" }),
slug: varchar({ length: 255 }).notNull().unique(), slug: varchar({ length: 255 }).notNull().unique(),
}, },
(t) => [primaryKey({ columns: [t.entry, t.video] })], (t) => [primaryKey({ columns: [t.entryPk, t.videoPk] })],
); );
export const videosRelations = relations(videos, ({ many }) => ({ export const videosRelations = relations(videos, ({ many }) => ({
@ -56,12 +59,12 @@ export const videosRelations = relations(videos, ({ many }) => ({
export const evjRelations = relations(entryVideoJoin, ({ one }) => ({ export const evjRelations = relations(entryVideoJoin, ({ one }) => ({
video: one(videos, { video: one(videos, {
relationName: "evj_video", relationName: "evj_video",
fields: [entryVideoJoin.video], fields: [entryVideoJoin.videoPk],
references: [videos.pk], references: [videos.pk],
}), }),
entry: one(entries, { entry: one(entries, {
relationName: "evj_entry", relationName: "evj_entry",
fields: [entryVideoJoin.entry], fields: [entryVideoJoin.entryPk],
references: [entries.pk], references: [entries.pk],
}), }),
})); }));

View File

@ -6,6 +6,7 @@ import { collections } from "./controllers/shows/collections";
import { movies } from "./controllers/shows/movies"; import { movies } from "./controllers/shows/movies";
import { series } from "./controllers/shows/series"; import { series } from "./controllers/shows/series";
import { showsH } from "./controllers/shows/shows"; import { showsH } from "./controllers/shows/shows";
import { studiosH } from "./controllers/studios";
import { videosH } from "./controllers/videos"; import { videosH } from "./controllers/videos";
import type { KError } from "./models/error"; import type { KError } from "./models/error";
@ -48,4 +49,5 @@ export const app = new Elysia()
.use(entriesH) .use(entriesH)
.use(seasonsH) .use(seasonsH)
.use(videosH) .use(videosH)
.use(studiosH)
.use(seed); .use(seed);

View File

@ -63,6 +63,7 @@ app
Can be used for administration or third party apps. Can be used for administration or third party apps.
`, `,
}, },
{ name: "studios", description: "Routes about studios" },
], ],
}, },
}), }),

View File

@ -2,6 +2,7 @@ import { t } from "elysia";
import type { Prettify } from "elysia/dist/types"; import type { Prettify } from "elysia/dist/types";
import { bubbleImages, duneCollection, registerExamples } from "./examples"; import { bubbleImages, duneCollection, registerExamples } from "./examples";
import { import {
DbMetadata,
ExternalId, ExternalId,
Genre, Genre,
Image, Image,
@ -33,10 +34,9 @@ const BaseCollection = t.Object({
}), }),
), ),
createdAt: t.String({ format: "date-time" }),
nextRefresh: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }),
externalId: ExternalId, externalId: ExternalId(),
}); });
export const CollectionTranslation = t.Object({ export const CollectionTranslation = t.Object({
@ -56,6 +56,7 @@ export const Collection = t.Intersect([
Resource(), Resource(),
CollectionTranslation, CollectionTranslation,
BaseCollection, BaseCollection,
DbMetadata,
]); ]);
export type Collection = Prettify<typeof Collection.static>; export type Collection = Prettify<typeof Collection.static>;
@ -68,13 +69,7 @@ export const FullCollection = t.Intersect([
export type FullCollection = Prettify<typeof FullCollection.static>; export type FullCollection = Prettify<typeof FullCollection.static>;
export const SeedCollection = t.Intersect([ export const SeedCollection = t.Intersect([
t.Omit(BaseCollection, [ t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]),
"kind",
"startAir",
"endAir",
"createdAt",
"nextRefresh",
]),
t.Object({ t.Object({
slug: t.String({ format: "slug" }), slug: t.String({ format: "slug" }),
translations: TranslationRecord( translations: TranslationRecord(

View File

@ -12,7 +12,6 @@ export const BaseEntry = () =>
), ),
thumbnail: t.Nullable(Image), thumbnail: t.Nullable(Image),
createdAt: t.String({ format: "date-time" }),
nextRefresh: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }),
}); });

View File

@ -1,7 +1,13 @@
import { t } from "elysia"; import { t } from "elysia";
import type { Prettify } from "~/utils"; import type { Prettify } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { EpisodeId, Resource, SeedImage, TranslationRecord } from "../utils"; import {
DbMetadata,
EpisodeId,
Resource,
SeedImage,
TranslationRecord,
} from "../utils";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseEpisode = t.Intersect([ export const BaseEpisode = t.Intersect([
@ -19,11 +25,12 @@ export const Episode = t.Intersect([
Resource(), Resource(),
EntryTranslation(), EntryTranslation(),
BaseEpisode, BaseEpisode,
DbMetadata,
]); ]);
export type Episode = Prettify<typeof Episode.static>; export type Episode = Prettify<typeof Episode.static>;
export const SeedEpisode = t.Intersect([ export const SeedEpisode = t.Intersect([
t.Omit(BaseEpisode, ["thumbnail", "createdAt", "nextRefresh"]), t.Omit(BaseEpisode, ["thumbnail", "nextRefresh"]),
t.Object({ t.Object({
thumbnail: t.Nullable(SeedImage), thumbnail: t.Nullable(SeedImage),
translations: TranslationRecord(EntryTranslation()), translations: TranslationRecord(EntryTranslation()),

View File

@ -1,7 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { madeInAbyss, registerExamples } from "../examples"; import { madeInAbyss, registerExamples } from "../examples";
import { SeedImage } from "../utils"; import { DbMetadata, SeedImage } from "../utils";
import { Resource } from "../utils/resource"; import { Resource } from "../utils/resource";
import { BaseEntry } from "./base-entry"; import { BaseEntry } from "./base-entry";
@ -31,11 +31,11 @@ export const BaseExtra = t.Intersect(
}, },
); );
export const Extra = t.Intersect([Resource(), BaseExtra]); export const Extra = t.Intersect([Resource(), BaseExtra, DbMetadata]);
export type Extra = Prettify<typeof Extra.static>; export type Extra = Prettify<typeof Extra.static>;
export const SeedExtra = t.Intersect([ export const SeedExtra = t.Intersect([
t.Omit(BaseExtra, ["thumbnail", "createdAt"]), t.Omit(BaseExtra, ["thumbnail"]),
t.Object({ t.Object({
slug: t.String({ format: "slug" }), slug: t.String({ format: "slug" }),
thumbnail: t.Nullable(SeedImage), thumbnail: t.Nullable(SeedImage),

View File

@ -2,6 +2,7 @@ import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { import {
DbMetadata,
ExternalId, ExternalId,
Image, Image,
Resource, Resource,
@ -18,7 +19,7 @@ export const BaseMovieEntry = t.Intersect(
minimum: 1, minimum: 1,
description: "Absolute playback order. Can be mixed with episodes.", description: "Absolute playback order. Can be mixed with episodes.",
}), }),
externalId: ExternalId, externalId: ExternalId(),
}), }),
BaseEntry(), BaseEntry(),
], ],
@ -42,11 +43,12 @@ export const MovieEntry = t.Intersect([
Resource(), Resource(),
MovieEntryTranslation, MovieEntryTranslation,
BaseMovieEntry, BaseMovieEntry,
DbMetadata,
]); ]);
export type MovieEntry = Prettify<typeof MovieEntry.static>; export type MovieEntry = Prettify<typeof MovieEntry.static>;
export const SeedMovieEntry = t.Intersect([ export const SeedMovieEntry = t.Intersect([
t.Omit(BaseMovieEntry, ["thumbnail", "createdAt", "nextRefresh"]), t.Omit(BaseMovieEntry, ["thumbnail", "nextRefresh"]),
t.Object({ t.Object({
slug: t.Optional(t.String({ format: "slug" })), slug: t.Optional(t.String({ format: "slug" })),
thumbnail: t.Nullable(SeedImage), thumbnail: t.Nullable(SeedImage),

View File

@ -1,7 +1,13 @@
import { t } from "elysia"; import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { EpisodeId, Resource, SeedImage, TranslationRecord } from "../utils"; import {
DbMetadata,
EpisodeId,
Resource,
SeedImage,
TranslationRecord,
} from "../utils";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseSpecial = t.Intersect( export const BaseSpecial = t.Intersect(
@ -29,11 +35,12 @@ export const Special = t.Intersect([
Resource(), Resource(),
EntryTranslation(), EntryTranslation(),
BaseSpecial, BaseSpecial,
DbMetadata,
]); ]);
export type Special = Prettify<typeof Special.static>; export type Special = Prettify<typeof Special.static>;
export const SeedSpecial = t.Intersect([ export const SeedSpecial = t.Intersect([
t.Omit(BaseSpecial, ["thumbnail", "createdAt", "nextRefresh"]), t.Omit(BaseSpecial, ["thumbnail", "nextRefresh"]),
t.Object({ t.Object({
thumbnail: t.Nullable(SeedImage), thumbnail: t.Nullable(SeedImage),
translations: TranslationRecord(EntryTranslation()), translations: TranslationRecord(EntryTranslation()),

View File

@ -1,8 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { bubbleImages, registerExamples } from "../examples"; import { bubbleImages, registerExamples, youtubeExample } from "../examples";
import { youtubeExample } from "../examples/others"; import { DbMetadata, Resource } from "../utils";
import { Resource } from "../utils/resource";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseUnknownEntry = t.Intersect( export const BaseUnknownEntry = t.Intersect(
@ -28,6 +27,7 @@ export const UnknownEntry = t.Intersect([
Resource(), Resource(),
UnknownEntryTranslation, UnknownEntryTranslation,
BaseUnknownEntry, BaseUnknownEntry,
DbMetadata,
]); ]);
export type UnknownEntry = Prettify<typeof UnknownEntry.static>; export type UnknownEntry = Prettify<typeof UnknownEntry.static>;

View File

@ -9,6 +9,7 @@ export const bubbleVideo: Video = {
part: null, part: null,
version: 1, version: 1,
createdAt: "2024-11-23T15:01:24.968Z", createdAt: "2024-11-23T15:01:24.968Z",
updatedAt: "2024-11-23T15:01:24.968Z",
}; };
export const bubble: SeedMovie = { export const bubble: SeedMovie = {
@ -60,6 +61,7 @@ export const bubble: SeedMovie = {
}, },
}, },
videos: [bubbleVideo.id], videos: [bubbleVideo.id],
studios: [],
}; };
export const bubbleImages = { export const bubbleImages = {

View File

@ -9,6 +9,7 @@ export const dune1984Video: Video = {
part: null, part: null,
version: 1, version: 1,
createdAt: "2024-12-02T11:45:12.968Z", createdAt: "2024-12-02T11:45:12.968Z",
updatedAt: "2024-12-02T11:45:12.968Z",
}; };
export const dune1984: SeedMovie = { export const dune1984: SeedMovie = {
@ -47,6 +48,7 @@ export const dune1984: SeedMovie = {
}, },
}, },
videos: [dune1984Video.id], videos: [dune1984Video.id],
studios: [],
}; };
export const dune1984Images = { export const dune1984Images = {

View File

@ -9,6 +9,7 @@ export const duneVideo: Video = {
part: null, part: null,
version: 1, version: 1,
createdAt: "2024-12-02T10:10:24.968Z", createdAt: "2024-12-02T10:10:24.968Z",
updatedAt: "2024-12-02T10:10:24.968Z",
}; };
export const dune: SeedMovie = { export const dune: SeedMovie = {
@ -47,6 +48,7 @@ export const dune: SeedMovie = {
}, },
}, },
videos: [duneVideo.id], videos: [duneVideo.id],
studios: [],
}; };
export const duneImages = { export const duneImages = {

View File

@ -16,6 +16,7 @@ export const madeInAbyssVideo: Video = {
from: "guessit", from: "guessit",
}, },
createdAt: "2024-11-23T15:01:24.968Z", createdAt: "2024-11-23T15:01:24.968Z",
updatedAt: "2024-11-23T15:01:24.968Z",
}; };
export const madeInAbyss = { export const madeInAbyss = {
@ -242,4 +243,21 @@ export const madeInAbyss = {
video: "3cd436ee-01ff-4f45-ba98-654282531234", video: "3cd436ee-01ff-4f45-ba98-654282531234",
}, },
], ],
studios: [
{
slug: "kinema-citrus",
translations: {
en: {
name: "Kinema Citrus",
logo: "https://image.tmdb.org/t/p/original/Lf0udeB7OwHoFJ0XIxVwfyGOqE.png",
},
},
externalId: {
themoviedatabase: {
dataId: "16738",
link: "https://www.themoviedb.org/company/16738",
},
},
},
],
} satisfies SeedSerie; } satisfies SeedSerie;

View File

@ -1,9 +1,10 @@
import { t } from "elysia"; import { t } from "elysia";
import type { Prettify } from "~/utils"; import type { Prettify } from "~/utils";
import { SeedCollection } from "./collections"; import { SeedCollection } from "./collections";
import { bubble, registerExamples } from "./examples"; import { bubble, bubbleImages, registerExamples } from "./examples";
import { bubbleImages } from "./examples/bubble"; import { SeedStudio, Studio } from "./studio";
import { import {
DbMetadata,
ExternalId, ExternalId,
Genre, Genre,
Image, Image,
@ -33,10 +34,9 @@ const BaseMovie = t.Object({
}), }),
), ),
createdAt: t.String({ format: "date-time" }),
nextRefresh: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }),
externalId: ExternalId, externalId: ExternalId(),
}); });
export const MovieTranslation = t.Object({ export const MovieTranslation = t.Object({
@ -58,6 +58,7 @@ export const Movie = t.Intersect([
Resource(), Resource(),
MovieTranslation, MovieTranslation,
BaseMovie, BaseMovie,
DbMetadata,
// t.Object({ isAvailable: t.Boolean() }), // t.Object({ isAvailable: t.Boolean() }),
]); ]);
export type Movie = Prettify<typeof Movie.static>; export type Movie = Prettify<typeof Movie.static>;
@ -67,12 +68,13 @@ export const FullMovie = t.Intersect([
t.Object({ t.Object({
translations: t.Optional(TranslationRecord(MovieTranslation)), translations: t.Optional(TranslationRecord(MovieTranslation)),
videos: t.Optional(t.Array(Video)), videos: t.Optional(t.Array(Video)),
studios: t.Optional(t.Array(Studio)),
}), }),
]); ]);
export type FullMovie = Prettify<typeof FullMovie.static>; export type FullMovie = Prettify<typeof FullMovie.static>;
export const SeedMovie = t.Intersect([ export const SeedMovie = t.Intersect([
t.Omit(BaseMovie, ["kind", "createdAt", "nextRefresh"]), t.Omit(BaseMovie, ["kind", "nextRefresh"]),
t.Object({ t.Object({
slug: t.String({ format: "slug", examples: ["bubble"] }), slug: t.String({ format: "slug", examples: ["bubble"] }),
translations: TranslationRecord( translations: TranslationRecord(
@ -88,6 +90,7 @@ export const SeedMovie = t.Intersect([
), ),
videos: t.Optional(t.Array(t.String({ format: "uuid" }))), videos: t.Optional(t.Array(t.String({ format: "uuid" }))),
collection: t.Optional(SeedCollection), collection: t.Optional(SeedCollection),
studios: t.Array(SeedStudio),
}), }),
]); ]);
export type SeedMovie = Prettify<typeof SeedMovie.static>; export type SeedMovie = Prettify<typeof SeedMovie.static>;

View File

@ -1,6 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import type { Prettify } from "~/utils"; import type { Prettify } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples";
import { DbMetadata } from "./utils";
import { SeasonId } from "./utils/external-id"; import { SeasonId } from "./utils/external-id";
import { Image, SeedImage } from "./utils/image"; import { Image, SeedImage } from "./utils/image";
import { TranslationRecord } from "./utils/language"; import { TranslationRecord } from "./utils/language";
@ -11,7 +12,6 @@ export const BaseSeason = t.Object({
startAir: t.Nullable(t.String({ format: "date" })), startAir: t.Nullable(t.String({ format: "date" })),
endAir: t.Nullable(t.String({ format: "date" })), endAir: t.Nullable(t.String({ format: "date" })),
createdAt: t.String({ format: "date-time" }),
nextRefresh: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }),
externalId: SeasonId, externalId: SeasonId,
@ -27,11 +27,16 @@ export const SeasonTranslation = t.Object({
}); });
export type SeasonTranslation = typeof SeasonTranslation.static; export type SeasonTranslation = typeof SeasonTranslation.static;
export const Season = t.Intersect([Resource(), SeasonTranslation, BaseSeason]); export const Season = t.Intersect([
export type Season = typeof Season.static; Resource(),
SeasonTranslation,
BaseSeason,
DbMetadata,
]);
export type Season = Prettify<typeof Season.static>;
export const SeedSeason = t.Intersect([ export const SeedSeason = t.Intersect([
t.Omit(BaseSeason, ["createdAt", "nextRefresh"]), t.Omit(BaseSeason, ["nextRefresh"]),
t.Object({ t.Object({
translations: TranslationRecord( translations: TranslationRecord(
t.Intersect([ t.Intersect([

View File

@ -4,11 +4,17 @@ import { SeedCollection } from "./collections";
import { SeedEntry, SeedExtra } from "./entry"; import { SeedEntry, SeedExtra } from "./entry";
import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples";
import { SeedSeason } from "./season"; import { SeedSeason } from "./season";
import { ExternalId } from "./utils/external-id"; import { SeedStudio, Studio } from "./studio";
import { Genre } from "./utils/genres"; import {
import { Image, SeedImage } from "./utils/image"; DbMetadata,
import { Language, TranslationRecord } from "./utils/language"; ExternalId,
import { Resource } from "./utils/resource"; Genre,
Image,
Language,
Resource,
SeedImage,
TranslationRecord,
} from "./utils";
export const SerieStatus = t.UnionEnum([ export const SerieStatus = t.UnionEnum([
"unknown", "unknown",
@ -18,7 +24,7 @@ export const SerieStatus = t.UnionEnum([
]); ]);
export type SerieStatus = typeof SerieStatus.static; export type SerieStatus = typeof SerieStatus.static;
export const BaseSerie = t.Object({ const BaseSerie = t.Object({
kind: t.Literal("serie"), kind: t.Literal("serie"),
genres: t.Array(Genre), genres: t.Array(Genre),
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
@ -38,10 +44,9 @@ export const BaseSerie = t.Object({
}), }),
), ),
createdAt: t.String({ format: "date-time" }),
nextRefresh: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }),
externalId: ExternalId, externalId: ExternalId(),
}); });
export const SerieTranslation = t.Object({ export const SerieTranslation = t.Object({
@ -59,19 +64,25 @@ export const SerieTranslation = t.Object({
}); });
export type SerieTranslation = typeof SerieTranslation.static; export type SerieTranslation = typeof SerieTranslation.static;
export const Serie = t.Intersect([Resource(), SerieTranslation, BaseSerie]); export const Serie = t.Intersect([
Resource(),
SerieTranslation,
BaseSerie,
DbMetadata,
]);
export type Serie = Prettify<typeof Serie.static>; export type Serie = Prettify<typeof Serie.static>;
export const FullSerie = t.Intersect([ export const FullSerie = t.Intersect([
Serie, Serie,
t.Object({ t.Object({
translations: t.Optional(TranslationRecord(SerieTranslation)), translations: t.Optional(TranslationRecord(SerieTranslation)),
studios: t.Optional(t.Array(Studio)),
}), }),
]); ]);
export type FullMovie = Prettify<typeof FullSerie.static>; export type FullMovie = Prettify<typeof FullSerie.static>;
export const SeedSerie = t.Intersect([ export const SeedSerie = t.Intersect([
t.Omit(BaseSerie, ["kind", "createdAt", "nextRefresh"]), t.Omit(BaseSerie, ["kind", "nextRefresh"]),
t.Object({ t.Object({
slug: t.String({ format: "slug" }), slug: t.String({ format: "slug" }),
translations: TranslationRecord( translations: TranslationRecord(
@ -89,6 +100,7 @@ export const SeedSerie = t.Intersect([
entries: t.Array(SeedEntry), entries: t.Array(SeedEntry),
extras: t.Optional(t.Array(SeedExtra)), extras: t.Optional(t.Array(SeedExtra)),
collection: t.Optional(SeedCollection), collection: t.Optional(SeedCollection),
studios: t.Array(SeedStudio),
}), }),
]); ]);
export type SeedSerie = typeof SeedSerie.static; export type SeedSerie = typeof SeedSerie.static;

6
api/src/models/show.ts Normal file
View File

@ -0,0 +1,6 @@
import { t } from "elysia";
import { Collection } from "./collections";
import { Movie } from "./movie";
import { Serie } from "./serie";
export const Show = t.Union([Movie, Serie, Collection]);

41
api/src/models/studio.ts Normal file
View File

@ -0,0 +1,41 @@
import { t } from "elysia";
import type { Prettify } from "elysia/dist/types";
import { bubbleImages, madeInAbyss, registerExamples } from "./examples";
import { DbMetadata, ExternalId, Resource, TranslationRecord } from "./utils";
import { Image, SeedImage } from "./utils/image";
const BaseStudio = t.Object({
externalId: ExternalId(),
});
export const StudioTranslation = t.Object({
name: t.String(),
logo: t.Nullable(Image),
});
export const Studio = t.Intersect([
Resource(),
StudioTranslation,
BaseStudio,
DbMetadata,
]);
export type Studio = Prettify<typeof Studio.static>;
export const SeedStudio = t.Intersect([
BaseStudio,
t.Object({
slug: t.String({ format: "slug" }),
translations: TranslationRecord(
t.Intersect([
t.Omit(StudioTranslation, ["logo"]),
t.Object({
logo: t.Nullable(SeedImage),
}),
]),
),
}),
]);
export type SeedStudio = Prettify<typeof SeedStudio.static>;
const ex = madeInAbyss.studios[0];
registerExamples(Studio, { ...ex, ...ex.translations.en, ...bubbleImages });

View File

@ -0,0 +1,6 @@
import { t } from "elysia";
export const DbMetadata = t.Object({
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
});

View File

@ -1,14 +1,14 @@
import { t } from "elysia"; import { t } from "elysia";
import { comment } from "../../utils"; import { comment } from "../../utils";
export const ExternalId = t.Record( export const ExternalId = () =>
t.String(), t.Record(
t.Object({ t.String(),
dataId: t.String(), t.Object({
link: t.Nullable(t.String({ format: "uri" })), dataId: t.String(),
}), link: t.Nullable(t.String({ format: "uri" })),
); }),
export type ExternalId = typeof ExternalId.static; );
export const EpisodeId = t.Record( export const EpisodeId = t.Record(
t.String(), t.String(),

View File

@ -7,3 +7,4 @@ export * from "./filters";
export * from "./page"; export * from "./page";
export * from "./sort"; export * from "./sort";
export * from "./keyset-paginate"; export * from "./keyset-paginate";
export * from "./db-metadata";

View File

@ -4,7 +4,9 @@ import {
type TSchema, type TSchema,
type TString, type TString,
} from "@sinclair/typebox"; } from "@sinclair/typebox";
import { type Column, type Table, eq, sql } from "drizzle-orm";
import { t } from "elysia"; import { t } from "elysia";
import { sqlarr } from "~/db/utils";
import { comment } from "../../utils"; import { comment } from "../../utils";
import { KErrorT } from "../error"; import { KErrorT } from "../error";
@ -106,3 +108,19 @@ export const AcceptLanguage = ({
` `
: ""), : ""),
}); });
export const selectTranslationQuery = (
translationTable: Table & { language: Column },
languages: string[],
) => ({
columns: {
pk: false,
} as const,
where: !languages.includes("*")
? eq(translationTable.language, sql`any(${sqlarr(languages)})`)
: undefined,
orderBy: [
sql`array_position(${sqlarr(languages)}, ${translationTable.language})`,
],
limit: 1,
});

View File

@ -1,10 +1,9 @@
import { type TSchema, t } from "elysia"; import { t } from "elysia";
import { comment } from "../utils"; import { type Prettify, comment } from "~/utils";
import { bubbleVideo, registerExamples } from "./examples"; import { bubbleVideo, registerExamples } from "./examples";
import { DbMetadata, Resource } from "./utils";
export const Video = t.Object({ export const SeedVideo = t.Object({
id: t.String({ format: "uuid" }),
slug: t.String({ format: "slug" }),
path: t.String(), path: t.String(),
rendering: t.String({ rendering: t.String({
description: comment` description: comment`
@ -30,8 +29,6 @@ export const Video = t.Object({
"Kyoo will prefer playing back the highest `version` number if there are multiples rendering.", "Kyoo will prefer playing back the highest `version` number if there are multiples rendering.",
}), }),
createdAt: t.String({ format: "date-time" }),
guess: t.Optional( guess: t.Optional(
t.Recursive((Self) => t.Recursive((Self) =>
t.Object( t.Object(
@ -69,8 +66,9 @@ export const Video = t.Object({
), ),
), ),
}); });
export type Video = typeof Video.static;
registerExamples(Video, bubbleVideo);
export const SeedVideo = t.Omit(Video, ["id", "slug", "createdAt"]);
export type SeedVideo = typeof SeedVideo.static; export type SeedVideo = typeof SeedVideo.static;
export const Video = t.Intersect([Resource(), SeedVideo, DbMetadata]);
export type Video = Prettify<typeof Video.static>;
registerExamples(Video, bubbleVideo);

View File

@ -1,5 +1,6 @@
export * from "./movies-helper"; export * from "./movies-helper";
export * from "./series-helper"; export * from "./series-helper";
export * from "./studio-helper";
export * from "./videos-helper"; export * from "./videos-helper";
export * from "~/elysia"; export * from "~/elysia";

View File

@ -4,7 +4,10 @@ import type { SeedMovie } from "~/models/movie";
export const getMovie = async ( export const getMovie = async (
id: string, id: string,
{ langs, ...query }: { langs?: string; preferOriginal?: boolean }, {
langs,
...query
}: { langs?: string; preferOriginal?: boolean; with?: string[] },
) => { ) => {
const resp = await app.handle( const resp = await app.handle(
new Request(buildUrl(`movies/${id}`, query), { new Request(buildUrl(`movies/${id}`, query), {

View File

@ -16,6 +16,27 @@ export const createSerie = async (serie: SeedSerie) => {
return [resp, body] as const; return [resp, body] as const;
}; };
export const getSerie = async (
id: string,
{
langs,
...query
}: { langs?: string; preferOriginal?: boolean; with?: string[] },
) => {
const resp = await app.handle(
new Request(buildUrl(`series/${id}`, query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
}
: {},
}),
);
const body = await resp.json();
return [resp, body] as const;
};
export const getSeasons = async ( export const getSeasons = async (
serie: string, serie: string,
{ {

View File

@ -0,0 +1,49 @@
import { buildUrl } from "tests/utils";
import { app } from "~/elysia";
export const getStudio = async (
id: string,
{ langs, ...query }: { langs?: string; preferOriginal?: boolean },
) => {
const resp = await app.handle(
new Request(buildUrl(`studios/${id}`, query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
}
: {},
}),
);
const body = await resp.json();
return [resp, body] as const;
};
export const getShowsByStudio = async (
studio: string,
{
langs,
...opts
}: {
filter?: string;
limit?: number;
after?: string;
sort?: string | string[];
query?: string;
langs?: string;
preferOriginal?: boolean;
},
) => {
const resp = await app.handle(
new Request(buildUrl(`studios/${studio}/shows`, opts), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
}
: {},
}),
);
const body = await resp.json();
return [resp, body] as const;
};

View File

@ -41,6 +41,7 @@ describe("with a null value", () => {
airDate: null, airDate: null,
originalLanguage: null, originalLanguage: null,
externalId: {}, externalId: {},
studios: [],
}); });
}); });

View File

@ -0,0 +1,64 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { getSerie, getShowsByStudio, getStudio } from "tests/helpers";
import { expectStatus } from "tests/utils";
import { seedSerie } from "~/controllers/seed/series";
import { madeInAbyss } from "~/models/examples";
beforeAll(async () => {
await seedSerie(madeInAbyss);
});
describe("Get by studio", () => {
it("Invalid slug", async () => {
const [resp, body] = await getShowsByStudio("sotneuhn", { langs: "en" });
expectStatus(resp, body).toBe(404);
expect(body).toMatchObject({
status: 404,
message: expect.any(String),
});
});
it("Get serie from studio", async () => {
const [resp, body] = await getShowsByStudio(madeInAbyss.studios[0].slug, {
langs: "en",
});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
expect(body.items[0].slug).toBe(madeInAbyss.slug);
});
});
describe("Get a studio", () => {
it("Invalid slug", async () => {
const [resp, body] = await getStudio("sotneuhn", { langs: "en" });
expectStatus(resp, body).toBe(404);
expect(body).toMatchObject({
status: 404,
message: expect.any(String),
});
});
it("Get by id", async () => {
const slug = madeInAbyss.studios[0].slug;
const [resp, body] = await getStudio(slug, { langs: "en" });
expectStatus(resp, body).toBe(200);
expect(body.slug).toBe(slug);
});
it("Get using /shows?with=", async () => {
const [resp, body] = await getSerie(madeInAbyss.slug, {
langs: "en",
with: ["studios"],
});
expectStatus(resp, body).toBe(200);
expect(body.slug).toBe(madeInAbyss.slug);
expect(body.studios).toBeArrayOfSize(1);
const studio = madeInAbyss.studios[0];
expect(body.studios[0]).toMatchObject({
slug: studio.slug,
name: studio.translations.en.name,
});
});
});