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

@ -98,22 +98,31 @@ erDiagram
seasons ||--o{ entries : has
shows ||--|{ seasons : has
users {
guid id PK
}
watched_shows {
guid show_id PK, FK
guid user_id PK, FK
status status "completed|watching|droped|planned"
status status "completed|watching|dropped|planned"
uint seen_entry_count "NN"
guid next_entry FK
}
shows ||--|{ watched_shows : has
users ||--|{ watched_shows : has
watched_shows ||--|o entries : next_entry
watched_entries {
guid entry_id PK, FK
guid user_id PK, FK
history {
int id PK
guid entry_id FK
guid user_id FK
uint time "in seconds, null of finished"
uint progress "NN, from 0 to 100"
datetime played_date
}
entries ||--|{ watched_entries : has
entries ||--|{ history : part_of
users ||--|{ history : has
roles {
guid show_id PK, FK
@ -160,5 +169,5 @@ erDiagram
string name
}
studios ||--|{ studio_translations : has
shows ||--|{ studios : 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,
"tag": "0009_collections",
"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(
db
.select({
entry: sql<number>`vids.entryPk::integer`.as("entry"),
video: sql`${videos.pk}`.as("video"),
entryPk: sql<number>`vids.entryPk::integer`.as("entry"),
videoPk: sql`${videos.pk}`.as("video"),
slug: computeVideoSlug(
sql`${show.slug}::text`,
sql`vids.needRendering::boolean`,
@ -154,7 +154,7 @@ export const insertEntries = async (
.onConflictDoNothing()
.returning({
slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entry,
entryPk: entryVideoJoin.entryPk,
});
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 { insertEntries } from "./insert/entries";
import { insertShow } from "./insert/shows";
import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh";
export const SeedMovieResponse = t.Object({
@ -18,6 +19,12 @@ export const SeedMovieResponse = t.Object({
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;
@ -38,7 +45,7 @@ export const seedMovie = async (
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 col = await insertCollection(collection, {
@ -74,11 +81,14 @@ export const seedMovie = async (
},
]);
const retStudios = await insertStudios(studios, show.pk);
return {
updated: show.updated,
id: show.id,
slug: show.slug,
videos: entry.videos,
collection: col,
studios: retStudios,
};
};

View File

@ -5,6 +5,7 @@ import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries";
import { insertSeasons } from "./insert/seasons";
import { insertShow } from "./insert/shows";
import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh";
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;
@ -65,7 +72,15 @@ export const seedSerie = async (
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 col = await insertCollection(collection, {
@ -92,6 +107,8 @@ export const seedSerie = async (
(extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })),
);
const retStudios = await insertStudios(studios, show.pk);
return {
updated: show.updated,
id: show.id,
@ -100,5 +117,6 @@ export const seedSerie = async (
entries: retEntries,
extras: retExtras,
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 { duneCollection } from "~/models/examples";
import { Movie } from "~/models/movie";
import { Serie } from "~/models/serie";
import { Show } from "~/models/show";
import {
AcceptLanguage,
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(
"/:id/movies",
async ({
@ -216,32 +246,6 @@ export const collections = new Elysia({
},
{
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: {
200: Page(Movie),
404: {
@ -297,34 +301,8 @@ export const collections = new Elysia({
},
{
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: {
200: Page(Movie),
200: Page(Serie),
404: {
...KError,
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" },
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: {
200: Page(Movie),
200: Page(Show),
404: {
...KError,
description: "No collection found with the given id or slug.",

View File

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

View File

@ -65,7 +65,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
preferOriginal: t.Optional(
t.Boolean({ description: desc.preferOriginal }),
),
with: t.Array(t.UnionEnum(["translations", "videos"]), {
with: t.Array(t.UnionEnum(["translations", "studios", "videos"]), {
default: [],
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(
t.Boolean({ description: desc.preferOriginal }),
),
with: t.Array(t.UnionEnum(["translations"]), {
with: t.Array(t.UnionEnum(["translations", "studios"]), {
default: [],
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 { db } from "~/db";
import { shows } from "~/db/schema";
import { Collection } from "~/models/collections";
import { KError } from "~/models/error";
import { Movie } from "~/models/movie";
import { Serie } from "~/models/serie";
import { Show } from "~/models/show";
import {
AcceptLanguage,
Filter,
@ -16,8 +14,6 @@ import {
import { desc } from "~/models/utils/descriptions";
import { getShows, showFilters, showSort } from "./logic";
const Show = t.Union([Movie, Serie, Collection]);
export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
.model({
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);
// TODO: this is a huge untested wip
// biome-ignore lint/correctness/noUnreachable: leave me alone
const vidsI = db.$with("vidsI").as(
db.insert(videos).values(body).onConflictDoNothing().returning({
pk: videos.pk,
id: videos.id,
path: videos.path,
guess: videos.guess,
}),
);
const findEntriesQ = db
.select({
guess: videos.guess,
entryPk: entries.pk,
showSlug: shows.slug,
// TODO: handle extras here
// guessit can't know if an episode is a special or not. treat specials like a normal episode.
kind: sql`
case when ${entries.kind} = 'movie' then 'movie' else 'episode' end
`.as("kind"),
season: entries.seasonNumber,
episode: entries.episodeNumber,
})
.from(entries)
.leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk))
.leftJoin(videos, eq(videos.pk, entryVideoJoin.video))
.leftJoin(shows, eq(shows.pk, entries.showPk))
.as("find_entries");
const hasRenderingQ = db
.select()
.from(entryVideoJoin)
.where(eq(entryVideoJoin.entry, findEntriesQ.entryPk));
const ret = await db
.with(vidsI)
.insert(entryVideoJoin)
.select(
db
.select({
entry: findEntriesQ.entryPk,
video: vidsI.pk,
slug: computeVideoSlug(
findEntriesQ.showSlug,
sql`exists(${hasRenderingQ})`,
),
})
.from(vidsI)
.leftJoin(
findEntriesQ,
and(
eq(
sql`${findEntriesQ.guess}->'title'`,
sql`${vidsI.guess}->'title'`,
),
// TODO: find if @> with a jsonb created on the fly is
// better than multiples checks
sql`${vidsI.guess} @> {"kind": }::jsonb`,
inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`),
inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`),
inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`),
),
),
)
.onConflictDoNothing()
.returning({
slug: entryVideoJoin.slug,
entryPk: entryVideoJoin.entry,
id: vidsI.id,
path: vidsI.path,
});
return error(201, ret as any);
// const vidsI = db.$with("vidsI").as(
// db.insert(videos).values(body).onConflictDoNothing().returning({
// pk: videos.pk,
// id: videos.id,
// path: videos.path,
// guess: videos.guess,
// }),
// );
//
// const findEntriesQ = db
// .select({
// guess: videos.guess,
// entryPk: entries.pk,
// showSlug: shows.slug,
// // TODO: handle extras here
// // guessit can't know if an episode is a special or not. treat specials like a normal episode.
// kind: sql`
// case when ${entries.kind} = 'movie' then 'movie' else 'episode' end
// `.as("kind"),
// season: entries.seasonNumber,
// episode: entries.episodeNumber,
// })
// .from(entries)
// .leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk))
// .leftJoin(videos, eq(videos.pk, entryVideoJoin.video))
// .leftJoin(shows, eq(shows.pk, entries.showPk))
// .as("find_entries");
//
// const hasRenderingQ = db
// .select()
// .from(entryVideoJoin)
// .where(eq(entryVideoJoin.entry, findEntriesQ.entryPk));
//
// const ret = await db
// .with(vidsI)
// .insert(entryVideoJoin)
// .select(
// db
// .select({
// entry: findEntriesQ.entryPk,
// video: vidsI.pk,
// slug: computeVideoSlug(
// findEntriesQ.showSlug,
// sql`exists(${hasRenderingQ})`,
// ),
// })
// .from(vidsI)
// .leftJoin(
// findEntriesQ,
// and(
// eq(
// sql`${findEntriesQ.guess}->'title'`,
// sql`${vidsI.guess}->'title'`,
// ),
// // TODO: find if @> with a jsonb created on the fly is
// // better than multiples checks
// sql`${vidsI.guess} @> {"kind": }::jsonb`,
// inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`),
// inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`),
// inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`),
// ),
// ),
// )
// .onConflictDoNothing()
// .returning({
// slug: entryVideoJoin.slug,
// entryPk: entryVideoJoin.entry,
// id: vidsI.id,
// path: vidsI.path,
// });
// return error(201, ret as any);
},
{
body: t.Array(SeedVideo),

View File

@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
import {
check,
date,
index,
integer,
jsonb,
primaryKey,
@ -70,11 +71,17 @@ export const entries = schema.table(
createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
},
(t) => [
unique().on(t.showPk, t.seasonNumber, t.episodeNumber),
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(),
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 }) => ({

View File

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

View File

@ -1,4 +1,4 @@
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
date,
index,
@ -45,11 +45,15 @@ export const seasons = schema.table(
createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
},
(t) => [
unique().on(t.showPk, t.seasonNumber),
index("show_fk").using("hash", t.showPk),
index("season_nbr").on(t.seasonNumber),
],
);
@ -66,7 +70,10 @@ export const seasonTranslations = schema.table(
thumbnail: 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 }) => ({

View File

@ -5,7 +5,6 @@ import {
date,
index,
integer,
jsonb,
primaryKey,
smallint,
text,
@ -15,7 +14,8 @@ import {
} from "drizzle-orm/pg-core";
import { entries } from "./entries";
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", [
"serie",
@ -54,20 +54,6 @@ export const genres = schema.enum("genres", [
"talk",
]);
export const externalid = () =>
jsonb()
.$type<
Record<
string,
{
dataId: string;
link: string | null;
}
>
>()
.notNull()
.default({});
export const shows = schema.table(
"shows",
{
@ -92,6 +78,9 @@ export const shows = schema.table(
createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
},
(t) => [
@ -141,6 +130,7 @@ export const showsRelations = relations(shows, ({ many, one }) => ({
}),
entries: many(entries, { relationName: "show_entries" }),
seasons: many(seasons, { relationName: "show_seasons" }),
studios: many(showStudioJoin, { relationName: "ssj_show" }),
}));
export const showsTrRelations = relations(showTranslations, ({ one }) => ({
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 = () =>
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" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.$onUpdate(() => sql`now()`),
},
(t) => [
check("part_pos", sql`${t.part} >= 0`),
@ -36,15 +39,15 @@ export const videos = schema.table(
export const entryVideoJoin = schema.table(
"entry_video_join",
{
entry: integer()
entryPk: integer()
.notNull()
.references(() => entries.pk, { onDelete: "cascade" }),
video: integer()
videoPk: integer()
.notNull()
.references(() => videos.pk, { onDelete: "cascade" }),
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 }) => ({
@ -56,12 +59,12 @@ export const videosRelations = relations(videos, ({ many }) => ({
export const evjRelations = relations(entryVideoJoin, ({ one }) => ({
video: one(videos, {
relationName: "evj_video",
fields: [entryVideoJoin.video],
fields: [entryVideoJoin.videoPk],
references: [videos.pk],
}),
entry: one(entries, {
relationName: "evj_entry",
fields: [entryVideoJoin.entry],
fields: [entryVideoJoin.entryPk],
references: [entries.pk],
}),
}));

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { t } from "elysia";
import { type Prettify, comment } from "~/utils";
import { madeInAbyss, registerExamples } from "../examples";
import { SeedImage } from "../utils";
import { DbMetadata, SeedImage } from "../utils";
import { Resource } from "../utils/resource";
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 const SeedExtra = t.Intersect([
t.Omit(BaseExtra, ["thumbnail", "createdAt"]),
t.Omit(BaseExtra, ["thumbnail"]),
t.Object({
slug: t.String({ format: "slug" }),
thumbnail: t.Nullable(SeedImage),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ export const madeInAbyssVideo: Video = {
from: "guessit",
},
createdAt: "2024-11-23T15:01:24.968Z",
updatedAt: "2024-11-23T15:01:24.968Z",
};
export const madeInAbyss = {
@ -242,4 +243,21 @@ export const madeInAbyss = {
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;

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,9 @@ import {
type TSchema,
type TString,
} from "@sinclair/typebox";
import { type Column, type Table, eq, sql } from "drizzle-orm";
import { t } from "elysia";
import { sqlarr } from "~/db/utils";
import { comment } from "../../utils";
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 { comment } from "../utils";
import { t } from "elysia";
import { type Prettify, comment } from "~/utils";
import { bubbleVideo, registerExamples } from "./examples";
import { DbMetadata, Resource } from "./utils";
export const Video = t.Object({
id: t.String({ format: "uuid" }),
slug: t.String({ format: "slug" }),
export const SeedVideo = t.Object({
path: t.String(),
rendering: t.String({
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.",
}),
createdAt: t.String({ format: "date-time" }),
guess: t.Optional(
t.Recursive((Self) =>
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 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 "./series-helper";
export * from "./studio-helper";
export * from "./videos-helper";
export * from "~/elysia";

View File

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

View File

@ -16,6 +16,27 @@ export const createSerie = async (serie: SeedSerie) => {
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 (
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,
originalLanguage: null,
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,
});
});
});