From bcec4c31d1ca79dba1ef512f983d6d9bd766907e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 19:08:10 +0100 Subject: [PATCH 01/11] Add staff types & db schema --- api/README.md | 19 ++-------- api/src/db/schema/index.ts | 1 + api/src/db/schema/shows.ts | 2 + api/src/db/schema/staff.ts | 76 ++++++++++++++++++++++++++++++++++++++ api/src/models/staff.ts | 34 +++++++++++++++++ 5 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 api/src/db/schema/staff.ts create mode 100644 api/src/models/staff.ts diff --git a/api/README.md b/api/README.md index df7a1dcf..6faa62eb 100644 --- a/api/README.md +++ b/api/README.md @@ -129,30 +129,19 @@ erDiagram guid staff_id PK, FK uint order type type "actor|director|writer|producer|music|other" + string character_name + string character_latin_name jsonb character_image } - - role_translations { - string language PK - string character_name - } - roles||--o{ role_translations : has - shows ||--|{ roles : has - staff { guid id PK string(256) slug UK + string name "NN" + string latin_name jsonb image datetime next_refresh jsonb external_id } - - staff_translations { - guid id PK,FK - string language PK - string name "NN" - } - staff ||--|{ staff_translations : has staff ||--|{ roles : has studios { diff --git a/api/src/db/schema/index.ts b/api/src/db/schema/index.ts index 4b5707eb..3488c4cc 100644 --- a/api/src/db/schema/index.ts +++ b/api/src/db/schema/index.ts @@ -2,4 +2,5 @@ export * from "./entries"; export * from "./seasons"; export * from "./shows"; export * from "./studios"; +export * from "./staff"; export * from "./videos"; diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index d00a5e6c..6729071f 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -16,6 +16,7 @@ import { import type { Image, Original } from "~/models/utils"; import { entries } from "./entries"; import { seasons } from "./seasons"; +import { roles } from "./staff"; import { showStudioJoin } from "./studios"; import { externalid, image, language, schema } from "./utils"; @@ -134,6 +135,7 @@ export const showsRelations = relations(shows, ({ many }) => ({ entries: many(entries, { relationName: "show_entries" }), seasons: many(seasons, { relationName: "show_seasons" }), studios: many(showStudioJoin, { relationName: "ssj_show" }), + staff: many(roles, { relationName: "show_roles" }), })); export const showsTrRelations = relations(showTranslations, ({ one }) => ({ show: one(shows, { diff --git a/api/src/db/schema/staff.ts b/api/src/db/schema/staff.ts new file mode 100644 index 00000000..351de0fb --- /dev/null +++ b/api/src/db/schema/staff.ts @@ -0,0 +1,76 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + integer, + jsonb, + primaryKey, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import type { Character } from "~/models/staff"; +import { shows } from "./shows"; +import { externalid, image, schema } from "./utils"; + +export const roleKind = schema.enum("role_kind", [ + "actor", + "director", + "writter", + "producer", + "music", + "other", +]); + +export const staff = schema.table("staff", { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + slug: varchar({ length: 255 }).notNull().unique(), + name: text().notNull(), + latinName: text(), + image: image(), + externalId: externalid(), + + createdAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), + updatedAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .$onUpdate(() => sql`now()`), +}); + +export const roles = schema.table( + "roles", + { + showPk: integer() + .notNull() + .references(() => shows.pk, { onDelete: "cascade" }), + staffPk: integer() + .notNull() + .references(() => staff.pk, { onDelete: "cascade" }), + kind: roleKind().notNull(), + order: integer().notNull(), + character: jsonb().$type(), + }, + (t) => [ + primaryKey({ columns: [t.showPk, t.staffPk] }), + index("role_kind").using("hash", t.kind), + index("role_order").on(t.order), + ], +); + +export const staffRelations = relations(staff, ({ many }) => ({ + roles: many(roles, { relationName: "staff_roles" }), +})); +export const rolesRelations = relations(roles, ({ one }) => ({ + staff: one(staff, { + relationName: "staff_roles", + fields: [roles.staffPk], + references: [staff.pk], + }), + show: one(shows, { + relationName: "show_roles", + fields: [roles.showPk], + references: [shows.pk], + }), +})); diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts new file mode 100644 index 00000000..62737f51 --- /dev/null +++ b/api/src/models/staff.ts @@ -0,0 +1,34 @@ +import { t } from "elysia"; +import { DbMetadata, ExternalId, Image, Resource } from "./utils"; + +export const Character = t.Object({ + name: t.String(), + latinName: t.String(), + image: t.Nullable(Image), +}); +export type Character = typeof Character.static; + +export const Role = t.Object({ + kind: t.UnionEnum([ + "actor", + "director", + "writter", + "producer", + "music", + "other", + ]), + character: t.Nullable(Character), +}); +export type Role = typeof Role.static; + +export const Staff = t.Intersect([ + Resource(), + t.Object({ + name: t.String(), + latinName: t.String(), + image: t.Nullable(Image), + externalId: ExternalId(), + }), + DbMetadata, +]); +export type Staff = typeof Staff.static; From 8d7d5f3e7e35eb4adde3e6e47505dd2959840507 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 19:26:47 +0100 Subject: [PATCH 02/11] Add /staff routes --- api/src/controllers/staff.ts | 94 ++++++++++++++++++++++++++++++++++ api/src/controllers/studios.ts | 4 +- api/src/elysia.ts | 2 + api/src/index.ts | 1 + 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 api/src/controllers/staff.ts diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts new file mode 100644 index 00000000..b43817ce --- /dev/null +++ b/api/src/controllers/staff.ts @@ -0,0 +1,94 @@ +import { sql } from "drizzle-orm"; +import Elysia, { t } from "elysia"; +import { db } from "~/db"; +import { staff } from "~/db/schema/staff"; +import { KError } from "~/models/error"; +import { Role, Staff } from "~/models/staff"; +import { Filter, Page, Sort } from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; + +export const staffH = new Elysia({ tags: ["staff"] }) + .model({ + staff: Staff, + role: Role, + }) + .get( + "/staff/:id", + async ({ params: { id }, error, set }) => { + throw new Error(); + }, + { + detail: { + description: "Get a staff member by id or slug.", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the staff to retrieve.", + example: "hiroyuki-sawano", + }), + }), + response: { + 200: "staff", + 404: { + ...KError, + description: "No staff found with the given id or slug.", + }, + }, + }, + ) + .get( + "/staff/random", + async ({ error, redirect }) => { + const [member] = await db + .select({ slug: staff.slug }) + .from(staff) + .orderBy(sql`random()`) + .limit(1); + if (!member) + return error(404, { + status: 404, + message: "No staff in the database.", + }); + return redirect(`/staff/${member.slug}`); + }, + { + detail: { + description: "Get a random staff member.", + }, + response: { + 302: t.Void({ + description: + "Redirected to the [/staff/{id}](#tag/staff/GET/staff/{id}) route.", + }), + 404: { + ...KError, + description: "No staff in the database.", + }, + }, + }, + ) + .get( + "/staff", + async ({ query: { limit, after, query }, request: { url } }) => { + throw new Error(); + }, + { + detail: { + description: "Get all staff members known by kyoo.", + }, + query: t.Object({ + 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 })), + }), + response: { + 200: Page(Staff), + 422: KError, + }, + }, + ); diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 68b3a3d3..66eb483b 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -156,7 +156,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) }, params: t.Object({ id: t.String({ - description: "The id or slug of the collection to retrieve.", + description: "The id or slug of the studio to retrieve.", example: "mappa", }), }), @@ -173,7 +173,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) 200: "studio", 404: { ...KError, - description: "No collection found with the given id or slug.", + description: "No studio found with the given id or slug.", }, 422: KError, }, diff --git a/api/src/elysia.ts b/api/src/elysia.ts index 561b23a9..c5c5f59e 100644 --- a/api/src/elysia.ts +++ b/api/src/elysia.ts @@ -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 { staffH } from "./controllers/staff"; import { studiosH } from "./controllers/studios"; import { videosH } from "./controllers/videos"; import type { KError } from "./models/error"; @@ -54,4 +55,5 @@ export const app = new Elysia() .use(seasonsH) .use(videosH) .use(studiosH) + .use(staffH) .use(seed); diff --git a/api/src/index.ts b/api/src/index.ts index 65e74f53..4c9a076e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -64,6 +64,7 @@ app `, }, { name: "studios", description: "Routes about studios" }, + { name: "staff", description: "Routes about staff & roles" }, ], }, }), From 7e6592fa2ee41080ea8a74ead5f4c980d803439f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 20:52:29 +0100 Subject: [PATCH 03/11] Implement routes in /shows --- api/src/controllers/staff.ts | 49 +++++++++++++++++++++++++++++----- api/src/controllers/studios.ts | 2 +- api/src/models/staff.ts | 4 +-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index b43817ce..f7581545 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -1,12 +1,21 @@ -import { sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { db } from "~/db"; import { staff } from "~/db/schema/staff"; import { KError } from "~/models/error"; import { Role, Staff } from "~/models/staff"; -import { Filter, Page, Sort } from "~/models/utils"; +import { + Page, + Sort, + createPage, + isUuid, + keysetPaginate, + sortToSql, +} from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; +const staffSort = Sort(["slug", "name", "latinName"], { default: ["slug"] }); + export const staffH = new Elysia({ tags: ["staff"] }) .model({ staff: Staff, @@ -14,8 +23,19 @@ export const staffH = new Elysia({ tags: ["staff"] }) }) .get( "/staff/:id", - async ({ params: { id }, error, set }) => { - throw new Error(); + async ({ params: { id }, error }) => { + const [ret] = await db + .select() + .from(staff) + .where(isUuid(id) ? eq(staff.id, id) : eq(staff.slug, id)) + .limit(1); + if (!ret) { + return error(404, { + status: 404, + message: `No staff found with the id or slug: '${id}'`, + }); + } + return ret; }, { detail: { @@ -69,14 +89,31 @@ export const staffH = new Elysia({ tags: ["staff"] }) ) .get( "/staff", - async ({ query: { limit, after, query }, request: { url } }) => { - throw new Error(); + async ({ query: { limit, after, sort, query }, request: { url } }) => { + const items = await db + .select() + .from(staff) + .where( + and( + query ? sql`${staff.name} %> ${query}::text` : undefined, + keysetPaginate({ table: staff, after, sort }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${staff.name})`] + : sortToSql(sort, staff)), + staff.pk, + ) + .limit(limit); + return createPage(items, { url, sort, limit }); }, { detail: { description: "Get all staff members known by kyoo.", }, query: t.Object({ + sort: staffSort, query: t.Optional(t.String({ description: desc.query })), limit: t.Integer({ minimum: 1, diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 66eb483b..8a9806bd 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -138,7 +138,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) if (!ret) { return error(404, { status: 404, - message: `No studio with the id or slug: '${id}'`, + message: `No studio found with the id or slug: '${id}'`, }); } if (!ret.language) { diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index 62737f51..66d8a727 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -3,7 +3,7 @@ import { DbMetadata, ExternalId, Image, Resource } from "./utils"; export const Character = t.Object({ name: t.String(), - latinName: t.String(), + latinName: t.Nullable(t.String()), image: t.Nullable(Image), }); export type Character = typeof Character.static; @@ -25,7 +25,7 @@ export const Staff = t.Intersect([ Resource(), t.Object({ name: t.String(), - latinName: t.String(), + latinName: t.Nullable(t.String()), image: t.Nullable(Image), externalId: ExternalId(), }), From cba47ca346083617d967f3a891c72f588bb8fe11 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 21:17:38 +0100 Subject: [PATCH 04/11] Add /staff/:id/roles --- api/src/controllers/staff.ts | 129 ++++++++++++++++++++++++++++++++++- api/src/models/staff.ts | 17 +++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index f7581545..fc50881f 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -1,21 +1,38 @@ import { and, eq, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { db } from "~/db"; -import { staff } from "~/db/schema/staff"; +import { showTranslations, shows } from "~/db/schema"; +import { roles, staff } from "~/db/schema/staff"; +import { getColumns, sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; -import { Role, Staff } from "~/models/staff"; +import type { MovieStatus } from "~/models/movie"; +import { Role, RoleWShow, Staff } from "~/models/staff"; import { + Filter, + type FilterDef, + type Image, Page, Sort, createPage, isUuid, keysetPaginate, + processLanguages, sortToSql, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; +import { showFilters, showSort } from "./shows/logic"; const staffSort = Sort(["slug", "name", "latinName"], { default: ["slug"] }); +const roleShowFilters: FilterDef = { + kind: { + column: roles.kind, + type: "enum", + values: Role.properties.kind.enum, + }, + ...showFilters, +}; + export const staffH = new Elysia({ tags: ["staff"] }) .model({ staff: Staff, @@ -87,6 +104,114 @@ export const staffH = new Elysia({ tags: ["staff"] }) }, }, ) + .get( + "/staff/:id/roles", + async ({ + params: { id }, + query: { limit, after, query, sort, filter, preferOriginal }, + headers: { "accept-language": languages }, + request: { url }, + error, + }) => { + const [member] = await db + .select({ pk: staff.pk }) + .from(staff) + .where(isUuid(id) ? eq(staff.id, id) : eq(staff.slug, id)) + .limit(1); + + if (!member) { + return error(404, { + status: 404, + message: `No staff member with the id or slug: '${id}'.`, + }); + } + + const langs = processLanguages(languages); + const transQ = db + .selectDistinctOn([showTranslations.pk]) + .from(showTranslations) + .orderBy( + showTranslations.pk, + sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`, + ) + .as("t"); + const items = await db + .select({ + ...getColumns(roles), + show: { + ...getColumns(shows), + ...getColumns(transQ), + + // movie columns (status is only a typescript hint) + status: sql`${shows.status}`, + airDate: shows.startAir, + kind: sql`${shows.kind}`, + isAvailable: sql`${shows.availableCount} != 0`, + + ...(preferOriginal && { + poster: sql`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`, + thumbnail: sql`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`, + banner: sql`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`, + logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, + }), + }, + }) + .from(roles) + .innerJoin(shows, eq(roles.showPk, shows.pk)) + .where( + and( + eq(roles.staffPk, member.pk), + filter, + query ? sql`${transQ.name} %> ${query}::text` : undefined, + keysetPaginate({ table: shows, after, sort }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${transQ.name})`] + : sortToSql(sort, shows)), + roles.showPk, + ) + .limit(limit); + return createPage(items, { url, sort, limit }); + }, + { + detail: { + description: "Get all roles this staff member worked as/on.", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the staff to retrieve.", + example: "hiroyuki-sawano", + }), + }), + query: t.Object({ + sort: showSort, + filter: t.Optional(Filter({ def: roleShowFilters })), + 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, + }), + ), + }), + response: { + 200: Page(RoleWShow), + 404: { + ...KError, + description: "No staff found with the given id or slug.", + }, + 422: KError, + }, + }, + ) .get( "/staff", async ({ query: { limit, after, sort, query }, request: { url } }) => { diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index 66d8a727..0c4be483 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -1,4 +1,5 @@ import { t } from "elysia"; +import { Show } from "./show"; import { DbMetadata, ExternalId, Image, Resource } from "./utils"; export const Character = t.Object({ @@ -32,3 +33,19 @@ export const Staff = t.Intersect([ DbMetadata, ]); export type Staff = typeof Staff.static; + +export const RoleWShow = t.Intersect([ + Role, + t.Object({ + show: Show, + }), +]); +export type RoleWShow = typeof RoleWShow.static; + +export const RoleWStaff = t.Intersect([ + Role, + t.Object({ + staff: Staff + }), +]); +export type RoleWStaff = typeof RoleWStaff.static; From 18532b80b15cdf41950db4842341c1147bf61851 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 21:41:38 +0100 Subject: [PATCH 05/11] Add /movies/:id/staff --- api/src/controllers/staff.ts | 98 +++++++++++++++++++++++++++++++++++- api/src/models/utils/sort.ts | 2 - 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index fc50881f..ad841c20 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -6,7 +6,7 @@ import { roles, staff } from "~/db/schema/staff"; import { getColumns, sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import type { MovieStatus } from "~/models/movie"; -import { Role, RoleWShow, Staff } from "~/models/staff"; +import { Role, RoleWShow, RoleWStaff, Staff } from "~/models/staff"; import { Filter, type FilterDef, @@ -24,12 +24,29 @@ import { showFilters, showSort } from "./shows/logic"; const staffSort = Sort(["slug", "name", "latinName"], { default: ["slug"] }); -const roleShowFilters: FilterDef = { +const staffRoleSort = Sort( + [ + "order", + // "slug", + // "name", + // "latinName", + // "characterName", "characterLatinName" + ], + { + default: ["order"], + }, +); + +const staffRoleFilter: FilterDef = { kind: { column: roles.kind, type: "enum", values: Role.properties.kind.enum, }, +}; + +const roleShowFilters: FilterDef = { + ...staffRoleFilter, ...showFilters, }; @@ -253,4 +270,81 @@ export const staffH = new Elysia({ tags: ["staff"] }) 422: KError, }, }, + ) + .get( + "/movies/:id/staff", + async ({ + params: { id }, + query: { limit, after, query, sort, filter }, + request: { url }, + error, + }) => { + const [movie] = await db + .select({ pk: shows.pk }) + .from(shows) + .where(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id)) + .limit(1); + + if (!movie) { + return error(404, { + status: 404, + message: `No movie with the id or slug: '${id}'.`, + }); + } + + const items = await db + .select({ + ...getColumns(roles), + staff: getColumns(staff), + }) + .from(roles) + .innerJoin(staff, eq(roles.staffPk, staff.pk)) + .where( + and( + filter, + query ? sql`${staff.name} %> ${query}::text` : undefined, + keysetPaginate({ table: roles, sort, after }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${staff.name})`] + : sortToSql(sort, roles)), + shows.pk, + ) + .limit(limit); + + return createPage(items, { url, sort, limit }); + }, + { + detail: { + description: "Get all staff member who worked on this movie", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the movie.", + example: "bubble", + }), + }), + query: t.Object({ + sort: staffRoleSort, + filter: t.Optional(Filter({ def: staffRoleFilter })), + 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 })), + }), + response: { + 200: Page(RoleWStaff), + 404: { + ...KError, + description: "No movie found with the given id or slug.", + }, + 422: KError, + }, + }, ); diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index 1a302325..06c3a3b6 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -43,8 +43,6 @@ export const Sort = < t.TemplateLiteral("random:${number}"), ]), { - // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia - explode: false, default: def, description: description, }, From 039880d8124a5255ca203b0d974b2de6394b8e35 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 21:47:36 +0100 Subject: [PATCH 06/11] Add /series/:id/staff --- api/src/controllers/staff.ts | 142 +++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 24 deletions(-) diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index ad841c20..70696fdd 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -1,4 +1,5 @@ -import { and, eq, sql } from "drizzle-orm"; +import type { StaticDecode } from "@sinclair/typebox"; +import { type SQL, and, eq, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { db } from "~/db"; import { showTranslations, shows } from "~/db/schema"; @@ -50,6 +51,42 @@ const roleShowFilters: FilterDef = { ...showFilters, }; +async function getStaffRoles({ + after, + limit, + query, + sort, + filter, +}: { + after?: string; + limit: number; + query?: string; + sort?: StaticDecode; + filter?: SQL; +}) { + return await db + .select({ + ...getColumns(roles), + staff: getColumns(staff), + }) + .from(roles) + .innerJoin(staff, eq(roles.staffPk, staff.pk)) + .where( + and( + filter, + query ? sql`${staff.name} %> ${query}::text` : undefined, + keysetPaginate({ table: roles, sort, after }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${staff.name})`] + : sortToSql(sort, roles)), + shows.pk, + ) + .limit(limit); +} + export const staffH = new Elysia({ tags: ["staff"] }) .model({ staff: Staff, @@ -282,7 +319,12 @@ export const staffH = new Elysia({ tags: ["staff"] }) const [movie] = await db .select({ pk: shows.pk }) .from(shows) - .where(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id)) + .where( + and( + eq(shows.kind, "movie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + ) .limit(1); if (!movie) { @@ -292,28 +334,13 @@ export const staffH = new Elysia({ tags: ["staff"] }) }); } - const items = await db - .select({ - ...getColumns(roles), - staff: getColumns(staff), - }) - .from(roles) - .innerJoin(staff, eq(roles.staffPk, staff.pk)) - .where( - and( - filter, - query ? sql`${staff.name} %> ${query}::text` : undefined, - keysetPaginate({ table: roles, sort, after }), - ), - ) - .orderBy( - ...(query - ? [sql`word_similarity(${query}::text, ${staff.name})`] - : sortToSql(sort, roles)), - shows.pk, - ) - .limit(limit); - + const items = await getStaffRoles({ + limit, + after, + query, + sort, + filter: and(eq(shows.pk, roles.showPk), filter), + }); return createPage(items, { url, sort, limit }); }, { @@ -347,4 +374,71 @@ export const staffH = new Elysia({ tags: ["staff"] }) 422: KError, }, }, + ) + .get( + "/series/:id/staff", + async ({ + params: { id }, + query: { limit, after, query, sort, filter }, + request: { url }, + error, + }) => { + const [serie] = await db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + eq(shows.kind, "serie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + ) + .limit(1); + + if (!serie) { + return error(404, { + status: 404, + message: `No serie with the id or slug: '${id}'.`, + }); + } + + const items = await getStaffRoles({ + limit, + after, + query, + sort, + filter: and(eq(shows.pk, roles.showPk), filter), + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { + description: "Get all staff member who worked on this serie", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the serie.", + example: "made-in-abyss", + }), + }), + query: t.Object({ + sort: staffRoleSort, + filter: t.Optional(Filter({ def: staffRoleFilter })), + 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 })), + }), + response: { + 200: Page(RoleWStaff), + 404: { + ...KError, + description: "No serie found with the given id or slug.", + }, + 422: KError, + }, + }, ); From 46833ac06f955aabbc76d33073243a7f8e01ebce Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 10 Mar 2025 00:16:35 +0100 Subject: [PATCH 07/11] Rework sort to handle multiples tables --- api/src/controllers/entries.ts | 39 ++++++----- api/src/controllers/seasons.ts | 21 ++++-- api/src/controllers/shows/logic.ts | 24 +++---- api/src/controllers/staff.ts | 46 +++++++++---- api/src/controllers/studios.ts | 15 ++++- api/src/models/utils/keyset-paginate.ts | 48 +++++--------- api/src/models/utils/page.ts | 10 +-- api/src/models/utils/sort.ts | 87 ++++++++++++++----------- 8 files changed, 165 insertions(+), 125 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index e132b96e..d3b11bc2 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -67,25 +67,32 @@ const unknownFilters: FilterDef = { }; const entrySort = Sort( - [ - "order", - "seasonNumber", - "episodeNumber", - "number", - "airDate", - "nextRefresh", - ], + { + order: entries.order, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + number: entries.episodeNumber, + airDate: entries.airDate, + nextRefresh: entries.nextRefresh, + }, { default: ["order"], - remap: { - number: "episodeNumber", - }, + tablePk: entries.pk, }, ); -const extraSort = Sort(["slug", "name", "runtime", "createdAt"], { - default: ["slug"], -}); +const extraSort = Sort( + { + slug: entries.slug, + name: entryTranslations.name, + runtime: entries.runtime, + createdAt: entries.createdAt, + }, + { + default: ["slug"], + tablePk: entries.pk, + }, +); async function getEntries({ after, @@ -166,13 +173,13 @@ async function getEntries({ and( filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: entries, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, entries)), + : sortToSql(sort)), entries.pk, ) .limit(limit); diff --git a/api/src/controllers/seasons.ts b/api/src/controllers/seasons.ts index f578bb44..d4559727 100644 --- a/api/src/controllers/seasons.ts +++ b/api/src/controllers/seasons.ts @@ -26,6 +26,19 @@ const seasonFilters: FilterDef = { endAir: { column: seasons.endAir, type: "date" }, }; +const seasonSort = Sort( + { + seasonNumber: seasons.seasonNumber, + startAir: seasons.startAir, + endAir: seasons.endAir, + nextRefresh: seasons.nextRefresh, + }, + { + default: ["seasonNumber"], + tablePk: seasons.pk, + }, +); + export const seasonsH = new Elysia({ tags: ["series"] }) .model({ season: Season, @@ -82,13 +95,13 @@ export const seasonsH = new Elysia({ tags: ["series"] }) eq(seasons.showPk, serie.pk), filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: seasons, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, seasons)), + : sortToSql(sort)), seasons.pk, ) .limit(limit); @@ -104,9 +117,7 @@ export const seasonsH = new Elysia({ tags: ["series"] }) }), }), query: t.Object({ - sort: Sort(["seasonNumber", "startAir", "endAir", "nextRefresh"], { - default: ["seasonNumber"], - }), + sort: seasonSort, filter: t.Optional(Filter({ def: seasonFilters })), query: t.Optional(t.String({ description: desc.query })), limit: t.Integer({ diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 311d3a16..50812663 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -57,18 +57,18 @@ export const showFilters: FilterDef = { }, }; export const showSort = Sort( - [ - "slug", - "rating", - "airDate", - "startAir", - "endAir", - "createdAt", - "nextRefresh", - ], { - remap: { airDate: "startAir" }, + slug: shows.slug, + rating: shows.rating, + airDate: shows.startAir, + startAir: shows.startAir, + endAir: shows.endAir, + createdAt: shows.createdAt, + nextRefresh: shows.nextRefresh, + }, + { default: ["slug"], + tablePk: shows.pk, }, ); @@ -209,13 +209,13 @@ export async function getShows({ and( filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: shows, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, shows)), + : sortToSql(sort)), shows.pk, ) .limit(limit); diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index 70696fdd..be2f3a3c 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -23,18 +23,36 @@ import { import { desc } from "~/models/utils/descriptions"; import { showFilters, showSort } from "./shows/logic"; -const staffSort = Sort(["slug", "name", "latinName"], { default: ["slug"] }); +const staffSort = Sort( + { + slug: staff.slug, + name: staff.name, + latinName: staff.latinName, + }, + { + default: ["slug"], + tablePk: staff.pk, + }, +); const staffRoleSort = Sort( - [ - "order", - // "slug", - // "name", - // "latinName", - // "characterName", "characterLatinName" - ], + { + order: roles.order, + slug: { sql: staff.slug, accessor: (x) => x.staff.slug }, + name: { sql: staff.name, accessor: (x) => x.staff.name }, + latinName: { sql: staff.latinName, accessor: (x) => x.staff.latinName }, + characterName: { + sql: sql`${roles.character}->'name'`, + accessor: (x) => x.character.name, + }, + characterLatinName: { + sql: sql`${roles.character}->'latinName'`, + accessor: (x) => x.character.latinName, + }, + }, { default: ["order"], + tablePk: staff.pk, }, ); @@ -75,13 +93,13 @@ async function getStaffRoles({ and( filter, query ? sql`${staff.name} %> ${query}::text` : undefined, - keysetPaginate({ table: roles, sort, after }), + keysetPaginate({ sort, after }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${staff.name})`] - : sortToSql(sort, roles)), + : sortToSql(sort)), shows.pk, ) .limit(limit); @@ -217,13 +235,13 @@ export const staffH = new Elysia({ tags: ["staff"] }) eq(roles.staffPk, member.pk), filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: shows, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, shows)), + : sortToSql(sort)), roles.showPk, ) .limit(limit); @@ -275,13 +293,13 @@ export const staffH = new Elysia({ tags: ["staff"] }) .where( and( query ? sql`${staff.name} %> ${query}::text` : undefined, - keysetPaginate({ table: staff, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${staff.name})`] - : sortToSql(sort, staff)), + : sortToSql(sort)), staff.pk, ) .limit(limit); diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 8a9806bd..04b1d290 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -34,7 +34,16 @@ import { import { desc } from "~/models/utils/descriptions"; import { getShows, showFilters, showSort } from "./shows/logic"; -const studioSort = Sort(["slug", "createdAt"], { default: ["slug"] }); +const studioSort = Sort( + { + slug: studios.slug, + createdAt: studios.createdAt, + }, + { + default: ["slug"], + tablePk: studios.pk, + }, +); const studioRelations = { translations: () => { @@ -101,13 +110,13 @@ export async function getStudios({ and( filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: studios, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, studios)), + : sortToSql(sort)), studios.pk, ) .limit(limit); diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 0307367f..fa91b93d 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -1,7 +1,5 @@ -import { type Column, and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; -import type { NonEmptyArray, Sort } from "./sort"; - -type Table = Record; +import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; +import type { Sort } from "./sort"; type After = (string | number | boolean | undefined)[]; @@ -16,37 +14,37 @@ type After = (string | number | boolean | undefined)[]; // (x > a) OR // (x = a AND y < b) OR // (x = a AND y = b AND z > c) OR... -export const keysetPaginate = < - const T extends NonEmptyArray, - const Remap extends Partial>, ->({ - table, +export const keysetPaginate = ({ sort, after, }: { - table: Table<"pk" | Sort["sort"][number]["key"]>; + sort: Sort | undefined; after: string | undefined; - sort: Sort | undefined; }) => { if (!after || !sort) return undefined; const cursor: After = JSON.parse( Buffer.from(after, "base64").toString("utf-8"), ); - const pkSort = { key: "pk" as const, desc: false }; + const pkSort = { + sql: sort.tablePk, + isNullable: false, + accessor: (x: any) => x.pk, + desc: false, + }; if (sort.random) { return or( gt( - sql`md5(${sort.random.seed} || ${table[pkSort.key]})`, + sql`md5(${sort.random.seed} || ${sort.tablePk})`, sql`md5(${sort.random.seed} || ${cursor[0]})`, ), and( eq( - sql`md5(${sort.random.seed} || ${table[pkSort.key]})`, + sql`md5(${sort.random.seed} || ${sort.tablePk})`, sql`md5(${sort.random.seed} || ${cursor[0]})`, ), - gt(table[pkSort.key], cursor[0]), + gt(sort.tablePk, cursor[0]), ), ); } @@ -62,31 +60,19 @@ export const keysetPaginate = < where, and( previous, - or( - cmp(table[by.key], cursor[i]), - !table[by.key].notNull ? isNull(table[by.key]) : undefined, - ), + or(cmp(by.sql, cursor[i]), by.isNullable ? isNull(by.sql) : undefined), ), ); previous = and( previous, - cursor[i] === null ? isNull(table[by.key]) : eq(table[by.key], cursor[i]), + cursor[i] === null ? isNull(by.sql) : eq(by.sql, cursor[i]), ); } return where; }; -export const generateAfter = < - const ST extends NonEmptyArray, - const Remap extends Partial> = never, ->( - cursor: any, - sort: Sort, -) => { - const ret = [ - ...sort.sort.map((by) => cursor[by.remmapedKey ?? by.key]), - cursor.pk, - ]; +export const generateAfter = (cursor: any, sort: Sort) => { + const ret = [...sort.sort.map((by) => by.accessor(cursor)), cursor.pk]; return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); }; diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index 56fad491..86a4b222 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -1,7 +1,7 @@ import type { ObjectOptions } from "@sinclair/typebox"; import { type TSchema, t } from "elysia"; import { generateAfter } from "./keyset-paginate"; -import type { NonEmptyArray, Sort } from "./sort"; +import type { Sort } from "./sort"; export const Page = (schema: T, options?: ObjectOptions) => t.Object( @@ -16,13 +16,9 @@ export const Page = (schema: T, options?: ObjectOptions) => }, ); -export const createPage = < - T, - const ST extends NonEmptyArray, - const Remap extends Partial> = never, ->( +export const createPage = ( items: T[], - { url, sort, limit }: { url: string; sort: Sort; limit: number }, + { url, sort, limit }: { url: string; sort: Sort; limit: number }, ) => { let next: string | null = null; const uri = new URL(url); diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index 06c3a3b6..cd3a053c 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -1,34 +1,40 @@ -import { sql } from "drizzle-orm"; +import { type SQL, type SQLWrapper, sql } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; import { t } from "elysia"; -export type Sort< - T extends string[], - Remap extends Partial>, -> = { +export type Sort = { + tablePk: SQLWrapper; sort: { - key: Exclude | NonNullable; - remmapedKey?: keyof Remap; + sql: SQLWrapper; + isNullable: boolean; + accessor: (cursor: any) => unknown; desc: boolean; }[]; random?: { seed: number }; }; -export type NonEmptyArray = [T, ...T[]]; - -export const Sort = < - const T extends NonEmptyArray, - const Remap extends Partial> = never, ->( - values: T, +export const Sort = ( + values: Record< + string, + | PgColumn + | { + sql: PgColumn; + accessor: (cursor: any) => unknown; + } + | { + sql: SQLWrapper; + isNullable: boolean; + accessor: (cursor: any) => unknown; + } + >, { description = "How to sort the query", default: def, - remap, + tablePk, }: { - default?: T[number][]; + default?: (keyof typeof values)[]; + tablePk: SQLWrapper; description?: string; - remap?: Remap; }, ) => t @@ -36,10 +42,10 @@ export const Sort = < t.Array( t.Union([ t.UnionEnum([ - ...values, - ...values.map((x: T[number]) => `-${x}` as const), + ...Object.keys(values), + ...Object.keys(values).map((x) => `-${x}`), "random", - ]), + ] as any), t.TemplateLiteral("random:${number}"), ]), { @@ -48,21 +54,36 @@ export const Sort = < }, ), ) - .Decode((sort): Sort => { + .Decode((sort: string[]): Sort => { const random = sort.find((x) => x.startsWith("random")); if (random) { const seed = random.includes(":") ? Number.parseInt(random.substring("random:".length)) : Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); - return { random: { seed }, sort: [] }; + return { tablePk, random: { seed }, sort: [] }; } return { + tablePk, sort: sort.map((x) => { const desc = x[0] === "-"; - const key = (desc ? x.substring(1) : x) as T[number]; - if (remap && key in remap) - return { key: remap[key]!, remmapedKey: key, desc }; - return { key: key as Exclude, desc }; + const key = desc ? x.substring(1) : x; + if ("getSQL" in values[key]) { + return { + sql: values[key], + isNullable: !values[key].notNull, + accessor: (x) => x[key], + desc, + }; + } + return { + sql: values[key].sql, + isNullable: + "isNullable" in values[key] + ? values[key].isNullable + : !values[key].sql.notNull, + accessor: values[key].accessor, + desc, + }; }), }; }) @@ -70,20 +91,12 @@ export const Sort = < throw new Error("Encode not supported for sort"); }); -type Table = Record; - -export const sortToSql = < - T extends string[], - Remap extends Partial>, ->( - sort: Sort | undefined, - table: Table["sort"][number]["key"] | "pk">, -) => { +export const sortToSql = (sort: Sort | undefined) => { if (!sort) return []; if (sort.random) { - return [sql`md5(${sort.random.seed} || ${table.pk})`]; + return [sql`md5(${sort.random.seed} || ${sort.tablePk})`]; } return sort.sort.map((x) => - x.desc ? sql`${table[x.key]} desc nulls last` : table[x.key], + x.desc ? sql`${x.sql} desc nulls last` : (x.sql as SQL), ); }; From 43a128ebe8aaaa6bda710577731983f609ae78c3 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 10 Mar 2025 01:31:51 +0100 Subject: [PATCH 08/11] Add staff seeding --- api/src/controllers/seed/insert/staff.ts | 47 +++++++++++++++++++++++ api/src/controllers/seed/movies.ts | 11 +++++- api/src/controllers/seed/series.ts | 10 +++++ api/src/controllers/staff.ts | 9 +++-- api/src/db/schema/staff.ts | 2 +- api/src/models/movie.ts | 2 + api/src/models/serie.ts | 2 + api/src/models/staff-roles.ts | 19 ++++++++++ api/src/models/staff.ts | 48 ++++++++++++------------ 9 files changed, 121 insertions(+), 29 deletions(-) create mode 100644 api/src/controllers/seed/insert/staff.ts create mode 100644 api/src/models/staff-roles.ts diff --git a/api/src/controllers/seed/insert/staff.ts b/api/src/controllers/seed/insert/staff.ts new file mode 100644 index 00000000..6512953e --- /dev/null +++ b/api/src/controllers/seed/insert/staff.ts @@ -0,0 +1,47 @@ +import { eq } from "drizzle-orm"; +import { db } from "~/db"; +import { roles, staff } from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/utils"; +import type { SeedStaff } from "~/models/staff"; +import { processOptImage } from "../images"; + +export const insertStaff = async ( + seed: SeedStaff[] | undefined, + showPk: number, +) => { + if (!seed?.length) return []; + + return await db.transaction(async (tx) => { + const people = seed.map((x) => ({ + ...x.staff, + image: processOptImage(x.staff.image), + })); + const ret = await tx + .insert(staff) + .values(people) + .onConflictDoUpdate({ + target: staff.slug, + set: conflictUpdateAllExcept(staff, ["pk", "id", "slug", "createdAt"]), + }) + .returning({ pk: staff.pk, id: staff.id, slug: staff.slug }); + + const rval = seed.map((x, i) => ({ + showPk, + staffPk: ret[i].pk, + kind: x.kind, + order: i, + character: { + ...x.character, + image: processOptImage(x.character.image), + }, + })); + + // always replace all roles. this is because: + // - we want `order` to stay in sync (& without duplicates) + // - we don't have ways to identify a role so we can't onConflict + await tx.delete(roles).where(eq(roles.showPk, showPk)); + await tx.insert(roles).values(rval); + + return ret; + }); +}; diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 28232697..ec54c2ab 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -5,6 +5,7 @@ import { processOptImage } from "./images"; import { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertShow, updateAvailableCount } from "./insert/shows"; +import { insertStaff } from "./insert/staff"; import { insertStudios } from "./insert/studios"; import { guessNextRefresh } from "./refresh"; @@ -26,6 +27,12 @@ export const SeedMovieResponse = t.Object({ slug: t.String({ format: "slug", examples: ["disney"] }), }), ), + staff: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["hiroyuki-sawano"] }), + }), + ), }); export type SeedMovieResponse = typeof SeedMovieResponse.static; @@ -46,7 +53,7 @@ export const seedMovie = async ( seed.slug = `random-${getYear(seed.airDate)}`; } - const { translations, videos, collection, studios, ...movie } = seed; + const { translations, videos, collection, studios, staff, ...movie } = seed; const nextRefresh = guessNextRefresh(movie.airDate ?? new Date()); const original = translations[movie.originalLanguage]; if (!original) { @@ -101,6 +108,7 @@ export const seedMovie = async ( await updateAvailableCount([show.pk], false); const retStudios = await insertStudios(studios, show.pk); + const retStaff = await insertStaff(staff, show.pk); return { updated: show.updated, @@ -109,5 +117,6 @@ export const seedMovie = async ( videos: entry.videos, collection: col, studios: retStudios, + staff: retStaff, }; }; diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index 5ca15eb6..e6981c26 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -6,6 +6,7 @@ import { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertSeasons } from "./insert/seasons"; import { insertShow, updateAvailableCount } from "./insert/shows"; +import { insertStaff } from "./insert/staff"; import { insertStudios } from "./insert/studios"; import { guessNextRefresh } from "./refresh"; @@ -53,6 +54,12 @@ export const SeedSerieResponse = t.Object({ slug: t.String({ format: "slug", examples: ["mappa"] }), }), ), + staff: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["hiroyuki-sawano"] }), + }), + ), }); export type SeedSerieResponse = typeof SeedSerieResponse.static; @@ -80,6 +87,7 @@ export const seedSerie = async ( extras, collection, studios, + staff, ...serie } = seed; const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); @@ -127,6 +135,7 @@ export const seedSerie = async ( await updateAvailableCount([show.pk]); const retStudios = await insertStudios(studios, show.pk); + const retStaff = await insertStaff(staff, show.pk); return { updated: show.updated, @@ -137,5 +146,6 @@ export const seedSerie = async ( extras: retExtras, collection: col, studios: retStudios, + staff: retStaff, }; }; diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index be2f3a3c..199dd691 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -7,7 +7,8 @@ import { roles, staff } from "~/db/schema/staff"; import { getColumns, sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import type { MovieStatus } from "~/models/movie"; -import { Role, RoleWShow, RoleWStaff, Staff } from "~/models/staff"; +import { Role, Staff } from "~/models/staff"; +import { RoleWShow, RoleWStaff } from "~/models/staff-roles"; import { Filter, type FilterDef, @@ -42,11 +43,13 @@ const staffRoleSort = Sort( name: { sql: staff.name, accessor: (x) => x.staff.name }, latinName: { sql: staff.latinName, accessor: (x) => x.staff.latinName }, characterName: { - sql: sql`${roles.character}->'name'`, + sql: sql`${roles.character}->>'name'`, + isNullable: true, accessor: (x) => x.character.name, }, characterLatinName: { - sql: sql`${roles.character}->'latinName'`, + sql: sql`${roles.character}->>'latinName'`, + isNullable: true, accessor: (x) => x.character.latinName, }, }, diff --git a/api/src/db/schema/staff.ts b/api/src/db/schema/staff.ts index 351de0fb..a23839cb 100644 --- a/api/src/db/schema/staff.ts +++ b/api/src/db/schema/staff.ts @@ -42,6 +42,7 @@ export const staff = schema.table("staff", { export const roles = schema.table( "roles", { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), showPk: integer() .notNull() .references(() => shows.pk, { onDelete: "cascade" }), @@ -53,7 +54,6 @@ export const roles = schema.table( character: jsonb().$type(), }, (t) => [ - primaryKey({ columns: [t.showPk, t.staffPk] }), index("role_kind").using("hash", t.kind), index("role_order").on(t.order), ], diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 86ed58d6..326f203a 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -2,6 +2,7 @@ import { t } from "elysia"; import type { Prettify } from "~/utils"; import { SeedCollection } from "./collections"; import { bubble, bubbleImages, registerExamples } from "./examples"; +import { SeedStaff } from "./staff"; import { SeedStudio, Studio } from "./studio"; import { DbMetadata, @@ -90,6 +91,7 @@ export const SeedMovie = t.Intersect([ videos: t.Optional(t.Array(t.String({ format: "uuid" }), { default: [] })), collection: t.Optional(SeedCollection), studios: t.Optional(t.Array(SeedStudio, { default: [] })), + staff: t.Optional(t.Array(SeedStaff, { default: [] })), }), ]); export type SeedMovie = Prettify; diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 00f6eb7c..3bafdb67 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -4,6 +4,7 @@ import { SeedCollection } from "./collections"; import { SeedEntry, SeedExtra } from "./entry"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { SeedSeason } from "./season"; +import { SeedStaff } from "./staff"; import { SeedStudio, Studio } from "./studio"; import { DbMetadata, @@ -106,6 +107,7 @@ export const SeedSerie = t.Intersect([ extras: t.Optional(t.Array(SeedExtra, { default: [] })), collection: t.Optional(SeedCollection), studios: t.Optional(t.Array(SeedStudio, { default: [] })), + staff: t.Optional(t.Array(SeedStaff, { default: [] })), }), ]); export type SeedSerie = typeof SeedSerie.static; diff --git a/api/src/models/staff-roles.ts b/api/src/models/staff-roles.ts new file mode 100644 index 00000000..4322ed6f --- /dev/null +++ b/api/src/models/staff-roles.ts @@ -0,0 +1,19 @@ +import { t } from "elysia"; +import { Show } from "./show"; +import { Role, Staff } from "./staff"; + +export const RoleWShow = t.Intersect([ + Role, + t.Object({ + show: Show, + }), +]); +export type RoleWShow = typeof RoleWShow.static; + +export const RoleWStaff = t.Intersect([ + Role, + t.Object({ + staff: Staff, + }), +]); +export type RoleWStaff = typeof RoleWStaff.static; diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index 0c4be483..0d5686c7 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -1,6 +1,5 @@ import { t } from "elysia"; -import { Show } from "./show"; -import { DbMetadata, ExternalId, Image, Resource } from "./utils"; +import { DbMetadata, ExternalId, Image, Resource, SeedImage } from "./utils"; export const Character = t.Object({ name: t.String(), @@ -22,30 +21,31 @@ export const Role = t.Object({ }); export type Role = typeof Role.static; -export const Staff = t.Intersect([ - Resource(), - t.Object({ - name: t.String(), - latinName: t.Nullable(t.String()), - image: t.Nullable(Image), - externalId: ExternalId(), - }), - DbMetadata, -]); +const StaffData = t.Object({ + name: t.String(), + latinName: t.Nullable(t.String()), + image: t.Nullable(Image), + externalId: ExternalId(), +}); +export const Staff = t.Intersect([Resource(), StaffData, DbMetadata]); export type Staff = typeof Staff.static; -export const RoleWShow = t.Intersect([ - Role, +export const SeedStaff = t.Intersect([ + t.Omit(Role, ["character"]), t.Object({ - show: Show, + character: t.Intersect([ + t.Omit(Character, ["image"]), + t.Object({ + image: t.Nullable(SeedImage), + }), + ]), + staff: t.Intersect([ + t.Object({ + slug: t.String({ format: "slug" }), + image: t.Nullable(SeedImage), + }), + t.Omit(StaffData, ["image"]), + ]), }), ]); -export type RoleWShow = typeof RoleWShow.static; - -export const RoleWStaff = t.Intersect([ - Role, - t.Object({ - staff: Staff - }), -]); -export type RoleWStaff = typeof RoleWStaff.static; +export type SeedStaff = typeof SeedStaff.static; From 25f042fbd70e29c9f79d82d3e5c2909e366a06b6 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 10 Mar 2025 10:22:31 +0100 Subject: [PATCH 09/11] Add staff examples --- api/src/models/examples/dune-collection.ts | 2 +- api/src/models/examples/made-in-abyss.ts | 22 +++++++++++++++++++++ api/src/models/staff.ts | 23 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/api/src/models/examples/dune-collection.ts b/api/src/models/examples/dune-collection.ts index 4c249975..4903f14b 100644 --- a/api/src/models/examples/dune-collection.ts +++ b/api/src/models/examples/dune-collection.ts @@ -5,7 +5,7 @@ export const duneCollection: SeedCollection = { translations: { en: { name: " Dune Collection", - tagline: "A mythic and emotionally charged hero's journey.", + tagline: "A mythic and emotionally charged hero journey.", description: "The saga of Paul Atreides and his rise to power on the deadly planet Arrakis.", aliases: [], diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index 36e16dd8..addcd277 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -295,4 +295,26 @@ export const madeInAbyss = { }, }, ], + staff: [ + { + kind: "actor", + character: { + name: "レグ", + latinName: "Reg", + image: "https://cdn.myanimelist.net/images/characters/4/326001.jpg", + }, + staff: { + slug: "mariya-ise", + name: "伊瀬茉莉也", + latinName: "Mariya Ise", + image: "https://cdn.myanimelist.net/images/voiceactors/2/65504.jpg", + externalId: { + themoviedatabase: { + dataId: "1250465", + link: "https://www.themoviedb.org/person/1250465", + }, + }, + }, + }, + ], } satisfies SeedSerie; diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index 0d5686c7..37aad31e 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -1,4 +1,5 @@ import { t } from "elysia"; +import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { DbMetadata, ExternalId, Image, Resource, SeedImage } from "./utils"; export const Character = t.Object({ @@ -49,3 +50,25 @@ export const SeedStaff = t.Intersect([ }), ]); export type SeedStaff = typeof SeedStaff.static; + +const role = madeInAbyss.staff[0]; +registerExamples(SeedStaff, role); +registerExamples(Staff, { + ...role.staff, + image: { + id: bubbleImages.poster.id, + source: role.staff.image, + blurhash: bubbleImages.poster.blurhash, + }, +}); +registerExamples(Role, { + ...role, + character: { + ...role.character, + image: { + id: bubbleImages.poster.id, + source: role.character.image, + blurhash: bubbleImages.poster.blurhash, + }, + }, +}); From e3a537896a183a35b60a1109a1ec5da5b735405d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 10 Mar 2025 11:29:12 +0100 Subject: [PATCH 10/11] Add staff migrations & basic tests --- api/drizzle/0014_staff.sql | 28 + api/drizzle/meta/0014_snapshot.json | 1487 +++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/controllers/entries.ts | 3 +- api/src/controllers/shows/logic.ts | 3 +- api/src/controllers/staff.ts | 3 +- api/src/controllers/studios.ts | 3 +- api/tests/helpers/index.ts | 1 + api/tests/helpers/staff-helper.ts | 41 + api/tests/series/get-staff.test.ts | 28 + 10 files changed, 1596 insertions(+), 8 deletions(-) create mode 100644 api/drizzle/0014_staff.sql create mode 100644 api/drizzle/meta/0014_snapshot.json create mode 100644 api/tests/helpers/staff-helper.ts create mode 100644 api/tests/series/get-staff.test.ts diff --git a/api/drizzle/0014_staff.sql b/api/drizzle/0014_staff.sql new file mode 100644 index 00000000..5dfd2277 --- /dev/null +++ b/api/drizzle/0014_staff.sql @@ -0,0 +1,28 @@ +CREATE TYPE "kyoo"."role_kind" AS ENUM('actor', 'director', 'writter', 'producer', 'music', 'other');--> statement-breakpoint +CREATE TABLE "kyoo"."roles" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."roles_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "show_pk" integer NOT NULL, + "staff_pk" integer NOT NULL, + "kind" "kyoo"."role_kind" NOT NULL, + "order" integer NOT NULL, + "character" jsonb +); +--> statement-breakpoint +CREATE TABLE "kyoo"."staff" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."staff_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, + "name" text NOT NULL, + "latin_name" text, + "image" jsonb, + "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 "staff_id_unique" UNIQUE("id"), + CONSTRAINT "staff_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +ALTER TABLE "kyoo"."roles" ADD CONSTRAINT "roles_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"."roles" ADD CONSTRAINT "roles_staff_pk_staff_pk_fk" FOREIGN KEY ("staff_pk") REFERENCES "kyoo"."staff"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "role_kind" ON "kyoo"."roles" USING hash ("kind");--> statement-breakpoint +CREATE INDEX "role_order" ON "kyoo"."roles" USING btree ("order"); \ No newline at end of file diff --git a/api/drizzle/meta/0014_snapshot.json b/api/drizzle/meta/0014_snapshot.json new file mode 100644 index 00000000..bc85b5be --- /dev/null +++ b/api/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1487 @@ +{ + "id": "e9cd782b-d7e2-42b8-8b4c-90b866f861ef", + "prevId": "45a7461e-96e1-45d0-a24f-74642db7bed3", + "version": "7", + "dialect": "postgresql", + "tables": { + "kyoo.entries": { + "name": "entries", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "entries_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "extra_kind": { + "name": "extra_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entry_kind": { + "name": "entry_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "entry_order": { + "name": "entry_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entries_show_pk_shows_pk_fk": { + "name": "entries_show_pk_shows_pk_fk", + "tableFrom": "entries", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "entries_showPk_seasonNumber_episodeNumber_unique": { + "name": "entries_showPk_seasonNumber_episodeNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number", "episode_number"] + } + }, + "policies": {}, + "checkConstraints": { + "order_positive": { + "name": "order_positive", + "value": "\"kyoo\".\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_translations": { + "name": "entry_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "entry_name_trgm": { + "name": "entry_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "entry_translations_pk_entries_pk_fk": { + "name": "entry_translations_pk_entries_pk_fk", + "tableFrom": "entry_translations", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_translations_pk_language_pk": { + "name": "entry_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.season_translations": { + "name": "season_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "season_name_trgm": { + "name": "season_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "season_nbr": { + "name": "season_nbr", + "columns": [ + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shows_collection_pk_shows_pk_fk": { + "name": "shows_collection_pk_shows_pk_fk", + "tableFrom": "shows", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["collection_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "shows_slug_unique": { + "name": "shows_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "rating_valid": { + "name": "rating_valid", + "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.show_studio_join": { + "name": "show_studio_join", + "schema": "kyoo", + "columns": { + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "studio_pk": { + "name": "studio_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_studio_join_show_pk_shows_pk_fk": { + "name": "show_studio_join_show_pk_shows_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_studio_join_studio_pk_studios_pk_fk": { + "name": "show_studio_join_studio_pk_studios_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["studio_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_studio_join_show_pk_studio_pk_pk": { + "name": "show_studio_join_show_pk_studio_pk_pk", + "columns": ["show_pk", "studio_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studio_translations": { + "name": "studio_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "studio_name_trgm": { + "name": "studio_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "studio_translations_pk_studios_pk_fk": { + "name": "studio_translations_pk_studios_pk_fk", + "tableFrom": "studio_translations", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "studio_translations_pk_language_pk": { + "name": "studio_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studios": { + "name": "studios", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "studios_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "studios_id_unique": { + "name": "studios_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "studios_slug_unique": { + "name": "studios_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.roles": { + "name": "roles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "roles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "staff_pk": { + "name": "staff_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "role_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "character": { + "name": "character", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_kind": { + "name": "role_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "role_order": { + "name": "role_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_show_pk_shows_pk_fk": { + "name": "roles_show_pk_shows_pk_fk", + "tableFrom": "roles", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roles_staff_pk_staff_pk_fk": { + "name": "roles_staff_pk_staff_pk_fk", + "tableFrom": "roles", + "tableTo": "staff", + "schemaTo": "kyoo", + "columnsFrom": ["staff_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.staff": { + "name": "staff", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "staff_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latin_name": { + "name": "latin_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_id_unique": { + "name": "staff_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "staff_slug_unique": { + "name": "staff_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_join_entry_pk_entries_pk_fk": { + "name": "entry_video_join_entry_pk_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_pk_videos_pk_fk": { + "name": "entry_video_join_video_pk_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_pk_video_pk_pk": { + "name": "entry_video_join_entry_pk_video_pk_pk", + "columns": ["entry_pk", "video_pk"] + } + }, + "uniqueConstraints": { + "entry_video_join_slug_unique": { + "name": "entry_video_join_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "videos_id_unique": { + "name": "videos_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["unknown", "episode", "movie", "special", "extra"] + }, + "kyoo.genres": { + "name": "genres", + "schema": "kyoo", + "values": [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk" + ] + }, + "kyoo.show_kind": { + "name": "show_kind", + "schema": "kyoo", + "values": ["serie", "movie", "collection"] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": ["unknown", "finished", "airing", "planned"] + }, + "kyoo.role_kind": { + "name": "role_kind", + "schema": "kyoo", + "values": ["actor", "director", "writter", "producer", "music", "other"] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index d4a0b9ff..75addf79 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1741444868735, "tag": "0013_original", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1741601145901, + "tag": "0014_staff", + "breakpoints": true } ] } diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index d3b11bc2..87433f61 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -1,4 +1,3 @@ -import type { StaticDecode } from "@sinclair/typebox"; import { type SQL, and, eq, ne, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { db } from "~/db"; @@ -105,7 +104,7 @@ async function getEntries({ after: string | undefined; limit: number; query: string | undefined; - sort: StaticDecode; + sort: Sort; filter: SQL | undefined; languages: string[]; }): Promise<(Entry | Extra | UnknownEntry)[]> { diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 50812663..6b9483c2 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -1,4 +1,3 @@ -import type { StaticDecode } from "@sinclair/typebox"; import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { db } from "~/db"; import { @@ -159,7 +158,7 @@ export async function getShows({ after?: string; limit: number; query?: string; - sort?: StaticDecode; + sort?: Sort; filter?: SQL; languages: string[]; fallbackLanguage?: boolean; diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index 199dd691..e1b1e7a8 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -1,4 +1,3 @@ -import type { StaticDecode } from "@sinclair/typebox"; import { type SQL, and, eq, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { db } from "~/db"; @@ -82,7 +81,7 @@ async function getStaffRoles({ after?: string; limit: number; query?: string; - sort?: StaticDecode; + sort?: Sort; filter?: SQL; }) { return await db diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 04b1d290..84af5a77 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -1,4 +1,3 @@ -import type { StaticDecode } from "@sinclair/typebox"; import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { db } from "~/db"; @@ -74,7 +73,7 @@ export async function getStudios({ after?: string; limit: number; query?: string; - sort?: StaticDecode; + sort?: Sort, filter?: SQL; languages: string[]; fallbackLanguage?: boolean; diff --git a/api/tests/helpers/index.ts b/api/tests/helpers/index.ts index e051fdff..d7dd1617 100644 --- a/api/tests/helpers/index.ts +++ b/api/tests/helpers/index.ts @@ -1,6 +1,7 @@ export * from "./movies-helper"; export * from "./series-helper"; export * from "./studio-helper"; +export * from "./staff-helper"; export * from "./videos-helper"; export * from "~/elysia"; diff --git a/api/tests/helpers/staff-helper.ts b/api/tests/helpers/staff-helper.ts new file mode 100644 index 00000000..255b4c60 --- /dev/null +++ b/api/tests/helpers/staff-helper.ts @@ -0,0 +1,41 @@ +import { buildUrl } from "tests/utils"; +import { app } from "~/elysia"; + +export const getStaff = async (id: string, query: {}) => { + const resp = await app.handle( + new Request(buildUrl(`staff/${id}`, query), { + method: "GET", + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +export const getStaffRoles = async ( + staff: 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(`staff/${staff}/roles`, opts), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/series/get-staff.test.ts b/api/tests/series/get-staff.test.ts new file mode 100644 index 00000000..0e0a87c6 --- /dev/null +++ b/api/tests/series/get-staff.test.ts @@ -0,0 +1,28 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { createSerie, getStaff, getStaffRoles } from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { madeInAbyss } from "~/models/examples"; + +beforeAll(async () => { + await createSerie(madeInAbyss); +}); + +describe("Get a staff member", () => { + it("Invalid slug", async () => { + const [resp, body] = await getStaff("sotneuhn", {}); + + expectStatus(resp, body).toBe(404); + expect(body).toMatchObject({ + status: 404, + message: expect.any(String), + }); + }); + it("Get staff by id", async () => { + const member = madeInAbyss.staff[0].staff; + const [resp, body] = await getStaff(member.slug, {}); + + expectStatus(resp, body).toBe(200); + expect(body.slug).toBe(member.slug); + expect(body.latinName).toBe(member.latinName); + }); +}); From dfaf5480adc37b71eda0c9c118d0fed337c1ab63 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 10 Mar 2025 14:10:28 +0100 Subject: [PATCH 11/11] Add tests & fix roles/staff --- api/src/controllers/staff.ts | 7 +++--- api/src/controllers/studios.ts | 2 +- api/tests/helpers/staff-helper.ts | 36 ++++++++++++++++++++++++++++++ api/tests/series/get-staff.test.ts | 28 ++++++++++++++++++++++- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index e1b1e7a8..56c1fa85 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -102,7 +102,7 @@ async function getStaffRoles({ ...(query ? [sql`word_similarity(${query}::text, ${staff.name})`] : sortToSql(sort)), - shows.pk, + staff.pk, ) .limit(limit); } @@ -232,6 +232,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) }) .from(roles) .innerJoin(shows, eq(roles.showPk, shows.pk)) + .innerJoin(transQ, eq(shows.pk, transQ.pk)) .where( and( eq(roles.staffPk, member.pk), @@ -359,7 +360,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) after, query, sort, - filter: and(eq(shows.pk, roles.showPk), filter), + filter: and(eq(roles.showPk, movie.pk), filter), }); return createPage(items, { url, sort, limit }); }, @@ -426,7 +427,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) after, query, sort, - filter: and(eq(shows.pk, roles.showPk), filter), + filter: and(eq(roles.showPk, serie.pk), filter), }); return createPage(items, { url, sort, limit }); }, diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 84af5a77..ea9bac91 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -73,7 +73,7 @@ export async function getStudios({ after?: string; limit: number; query?: string; - sort?: Sort, + sort?: Sort; filter?: SQL; languages: string[]; fallbackLanguage?: boolean; diff --git a/api/tests/helpers/staff-helper.ts b/api/tests/helpers/staff-helper.ts index 255b4c60..4c790de1 100644 --- a/api/tests/helpers/staff-helper.ts +++ b/api/tests/helpers/staff-helper.ts @@ -39,3 +39,39 @@ export const getStaffRoles = async ( const body = await resp.json(); return [resp, body] as const; }; + +export const getSerieStaff = async ( + serie: string, + opts: { + filter?: string; + limit?: number; + after?: string; + sort?: string | string[]; + }, +) => { + const resp = await app.handle( + new Request(buildUrl(`series/${serie}/staff`, opts), { + method: "GET", + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +export const getMovieStaff = async ( + movie: string, + opts: { + filter?: string; + limit?: number; + after?: string; + sort?: string | string[]; + }, +) => { + const resp = await app.handle( + new Request(buildUrl(`movies/${movie}/staff`, opts), { + method: "GET", + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/series/get-staff.test.ts b/api/tests/series/get-staff.test.ts index 0e0a87c6..63786d62 100644 --- a/api/tests/series/get-staff.test.ts +++ b/api/tests/series/get-staff.test.ts @@ -1,5 +1,10 @@ import { beforeAll, describe, expect, it } from "bun:test"; -import { createSerie, getStaff, getStaffRoles } from "tests/helpers"; +import { + createSerie, + getSerieStaff, + getStaff, + getStaffRoles, +} from "tests/helpers"; import { expectStatus } from "tests/utils"; import { madeInAbyss } from "~/models/examples"; @@ -25,4 +30,25 @@ describe("Get a staff member", () => { expect(body.slug).toBe(member.slug); expect(body.latinName).toBe(member.latinName); }); + it("Get staff's roles", async () => { + const role = madeInAbyss.staff[0]; + const [resp, body] = await getStaffRoles(role.staff.slug, {}); + + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(1); + expect(body.items[0].kind).toBe(role.kind); + expect(body.items[0].character.name).toBe(role.character.name); + expect(body.items[0].show.slug).toBe(madeInAbyss.slug); + }); + it("Get series's staff", async () => { + const role = madeInAbyss.staff[0]; + const [resp, body] = await getSerieStaff(madeInAbyss.slug, {}); + + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(1); + expect(body.items[0].kind).toBe(role.kind); + expect(body.items[0].character.name).toBe(role.character.name); + expect(body.items[0].staff.slug).toBe(role.staff.slug); + expect(body.items[0].staff.name).toBe(role.staff.name); + }); });