mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-06-23 15:30:34 -04:00
Add studios (#824)
This commit is contained in:
commit
250c9c8ff9
@ -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
|
||||
```
|
||||
|
38
api/drizzle/0010_studios.sql
Normal file
38
api/drizzle/0010_studios.sql
Normal 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");
|
20
api/drizzle/0011_join_rename.sql
Normal file
20
api/drizzle/0011_join_rename.sql
Normal 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;
|
1265
api/drizzle/meta/0010_snapshot.json
Normal file
1265
api/drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1265
api/drizzle/meta/0011_snapshot.json
Normal file
1265
api/drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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) => ({
|
||||
|
55
api/src/controllers/seed/insert/studios.ts
Normal file
55
api/src/controllers/seed/insert/studios.ts
Normal 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;
|
||||
});
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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" },
|
||||
});
|
@ -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.",
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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.",
|
||||
}),
|
||||
|
@ -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.",
|
||||
}),
|
||||
|
@ -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,
|
||||
|
412
api/src/controllers/studios.ts
Normal file
412
api/src/controllers/studios.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
);
|
@ -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),
|
||||
|
@ -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 }) => ({
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./entries";
|
||||
export * from "./seasons";
|
||||
export * from "./shows";
|
||||
export * from "./studios";
|
||||
export * from "./videos";
|
||||
|
@ -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 }) => ({
|
||||
|
@ -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, {
|
||||
|
89
api/src/db/schema/studios.ts
Normal file
89
api/src/db/schema/studios.ts
Normal 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],
|
||||
}),
|
||||
}));
|
@ -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({});
|
||||
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
@ -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);
|
||||
|
@ -63,6 +63,7 @@ app
|
||||
Can be used for administration or third party apps.
|
||||
`,
|
||||
},
|
||||
{ name: "studios", description: "Routes about studios" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
@ -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(
|
||||
|
@ -12,7 +12,6 @@ export const BaseEntry = () =>
|
||||
),
|
||||
thumbnail: t.Nullable(Image),
|
||||
|
||||
createdAt: t.String({ format: "date-time" }),
|
||||
nextRefresh: t.String({ format: "date-time" }),
|
||||
});
|
||||
|
||||
|
@ -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()),
|
||||
|
@ -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),
|
||||
|
@ -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),
|
||||
|
@ -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()),
|
||||
|
@ -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>;
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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 = {
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
|
@ -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([
|
||||
|
@ -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
6
api/src/models/show.ts
Normal 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
41
api/src/models/studio.ts
Normal 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 });
|
6
api/src/models/utils/db-metadata.ts
Normal file
6
api/src/models/utils/db-metadata.ts
Normal 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" }),
|
||||
});
|
@ -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(),
|
||||
|
@ -7,3 +7,4 @@ export * from "./filters";
|
||||
export * from "./page";
|
||||
export * from "./sort";
|
||||
export * from "./keyset-paginate";
|
||||
export * from "./db-metadata";
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -1,5 +1,6 @@
|
||||
export * from "./movies-helper";
|
||||
export * from "./series-helper";
|
||||
export * from "./studio-helper";
|
||||
export * from "./videos-helper";
|
||||
|
||||
export * from "~/elysia";
|
||||
|
@ -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), {
|
||||
|
@ -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,
|
||||
{
|
||||
|
49
api/tests/helpers/studio-helper.ts
Normal file
49
api/tests/helpers/studio-helper.ts
Normal 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;
|
||||
};
|
@ -41,6 +41,7 @@ describe("with a null value", () => {
|
||||
airDate: null,
|
||||
originalLanguage: null,
|
||||
externalId: {},
|
||||
studios: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
64
api/tests/series/studios.test.ts
Normal file
64
api/tests/series/studios.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user