Add staff & role APIs (#835)

This commit is contained in:
Zoe Roux 2025-03-10 14:11:59 +01:00 committed by GitHub
commit 8687cf8de0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2531 additions and 139 deletions

View File

@ -129,30 +129,19 @@ erDiagram
guid staff_id PK, FK guid staff_id PK, FK
uint order uint order
type type "actor|director|writer|producer|music|other" type type "actor|director|writer|producer|music|other"
string character_name
string character_latin_name
jsonb character_image jsonb character_image
} }
role_translations {
string language PK
string character_name
}
roles||--o{ role_translations : has
shows ||--|{ roles : has
staff { staff {
guid id PK guid id PK
string(256) slug UK string(256) slug UK
string name "NN"
string latin_name
jsonb image jsonb image
datetime next_refresh datetime next_refresh
jsonb external_id jsonb external_id
} }
staff_translations {
guid id PK,FK
string language PK
string name "NN"
}
staff ||--|{ staff_translations : has
staff ||--|{ roles : has staff ||--|{ roles : has
studios { studios {

View File

@ -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");

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,13 @@
"when": 1741444868735, "when": 1741444868735,
"tag": "0013_original", "tag": "0013_original",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1741601145901,
"tag": "0014_staff",
"breakpoints": true
} }
] ]
} }

View File

@ -1,4 +1,3 @@
import type { StaticDecode } from "@sinclair/typebox";
import { type SQL, and, eq, ne, sql } from "drizzle-orm"; import { type SQL, and, eq, ne, sql } from "drizzle-orm";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { db } from "~/db"; import { db } from "~/db";
@ -67,25 +66,32 @@ const unknownFilters: FilterDef = {
}; };
const entrySort = Sort( const entrySort = Sort(
[ {
"order", order: entries.order,
"seasonNumber", seasonNumber: entries.seasonNumber,
"episodeNumber", episodeNumber: entries.episodeNumber,
"number", number: entries.episodeNumber,
"airDate", airDate: entries.airDate,
"nextRefresh", nextRefresh: entries.nextRefresh,
], },
{ {
default: ["order"], default: ["order"],
remap: { tablePk: entries.pk,
number: "episodeNumber",
},
}, },
); );
const extraSort = Sort(["slug", "name", "runtime", "createdAt"], { const extraSort = Sort(
{
slug: entries.slug,
name: entryTranslations.name,
runtime: entries.runtime,
createdAt: entries.createdAt,
},
{
default: ["slug"], default: ["slug"],
}); tablePk: entries.pk,
},
);
async function getEntries({ async function getEntries({
after, after,
@ -98,7 +104,7 @@ async function getEntries({
after: string | undefined; after: string | undefined;
limit: number; limit: number;
query: string | undefined; query: string | undefined;
sort: StaticDecode<typeof entrySort>; sort: Sort;
filter: SQL | undefined; filter: SQL | undefined;
languages: string[]; languages: string[];
}): Promise<(Entry | Extra | UnknownEntry)[]> { }): Promise<(Entry | Extra | UnknownEntry)[]> {
@ -166,13 +172,13 @@ async function getEntries({
and( and(
filter, filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined, query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ table: entries, after, sort }), keysetPaginate({ after, sort }),
), ),
) )
.orderBy( .orderBy(
...(query ...(query
? [sql`word_similarity(${query}::text, ${transQ.name})`] ? [sql`word_similarity(${query}::text, ${transQ.name})`]
: sortToSql(sort, entries)), : sortToSql(sort)),
entries.pk, entries.pk,
) )
.limit(limit); .limit(limit);

View File

@ -26,6 +26,19 @@ const seasonFilters: FilterDef = {
endAir: { column: seasons.endAir, type: "date" }, 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"] }) export const seasonsH = new Elysia({ tags: ["series"] })
.model({ .model({
season: Season, season: Season,
@ -82,13 +95,13 @@ export const seasonsH = new Elysia({ tags: ["series"] })
eq(seasons.showPk, serie.pk), eq(seasons.showPk, serie.pk),
filter, filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined, query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ table: seasons, after, sort }), keysetPaginate({ after, sort }),
), ),
) )
.orderBy( .orderBy(
...(query ...(query
? [sql`word_similarity(${query}::text, ${transQ.name})`] ? [sql`word_similarity(${query}::text, ${transQ.name})`]
: sortToSql(sort, seasons)), : sortToSql(sort)),
seasons.pk, seasons.pk,
) )
.limit(limit); .limit(limit);
@ -104,9 +117,7 @@ export const seasonsH = new Elysia({ tags: ["series"] })
}), }),
}), }),
query: t.Object({ query: t.Object({
sort: Sort(["seasonNumber", "startAir", "endAir", "nextRefresh"], { sort: seasonSort,
default: ["seasonNumber"],
}),
filter: t.Optional(Filter({ def: seasonFilters })), filter: t.Optional(Filter({ def: seasonFilters })),
query: t.Optional(t.String({ description: desc.query })), query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({ limit: t.Integer({

View File

@ -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;
});
};

View File

@ -5,6 +5,7 @@ import { processOptImage } from "./images";
import { insertCollection } from "./insert/collection"; import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertShow, updateAvailableCount } from "./insert/shows"; import { insertShow, updateAvailableCount } from "./insert/shows";
import { insertStaff } from "./insert/staff";
import { insertStudios } from "./insert/studios"; import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh"; import { guessNextRefresh } from "./refresh";
@ -26,6 +27,12 @@ export const SeedMovieResponse = t.Object({
slug: t.String({ format: "slug", examples: ["disney"] }), 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; export type SeedMovieResponse = typeof SeedMovieResponse.static;
@ -46,7 +53,7 @@ export const seedMovie = async (
seed.slug = `random-${getYear(seed.airDate)}`; 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 nextRefresh = guessNextRefresh(movie.airDate ?? new Date());
const original = translations[movie.originalLanguage]; const original = translations[movie.originalLanguage];
if (!original) { if (!original) {
@ -101,6 +108,7 @@ export const seedMovie = async (
await updateAvailableCount([show.pk], false); await updateAvailableCount([show.pk], false);
const retStudios = await insertStudios(studios, show.pk); const retStudios = await insertStudios(studios, show.pk);
const retStaff = await insertStaff(staff, show.pk);
return { return {
updated: show.updated, updated: show.updated,
@ -109,5 +117,6 @@ export const seedMovie = async (
videos: entry.videos, videos: entry.videos,
collection: col, collection: col,
studios: retStudios, studios: retStudios,
staff: retStaff,
}; };
}; };

View File

@ -6,6 +6,7 @@ import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertSeasons } from "./insert/seasons"; import { insertSeasons } from "./insert/seasons";
import { insertShow, updateAvailableCount } from "./insert/shows"; import { insertShow, updateAvailableCount } from "./insert/shows";
import { insertStaff } from "./insert/staff";
import { insertStudios } from "./insert/studios"; import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh"; import { guessNextRefresh } from "./refresh";
@ -53,6 +54,12 @@ export const SeedSerieResponse = t.Object({
slug: t.String({ format: "slug", examples: ["mappa"] }), 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; export type SeedSerieResponse = typeof SeedSerieResponse.static;
@ -80,6 +87,7 @@ export const seedSerie = async (
extras, extras,
collection, collection,
studios, studios,
staff,
...serie ...serie
} = seed; } = seed;
const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); const nextRefresh = guessNextRefresh(serie.startAir ?? new Date());
@ -127,6 +135,7 @@ export const seedSerie = async (
await updateAvailableCount([show.pk]); await updateAvailableCount([show.pk]);
const retStudios = await insertStudios(studios, show.pk); const retStudios = await insertStudios(studios, show.pk);
const retStaff = await insertStaff(staff, show.pk);
return { return {
updated: show.updated, updated: show.updated,
@ -137,5 +146,6 @@ export const seedSerie = async (
extras: retExtras, extras: retExtras,
collection: col, collection: col,
studios: retStudios, studios: retStudios,
staff: retStaff,
}; };
}; };

View File

@ -1,4 +1,3 @@
import type { StaticDecode } from "@sinclair/typebox";
import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { type SQL, and, eq, exists, sql } from "drizzle-orm";
import { db } from "~/db"; import { db } from "~/db";
import { import {
@ -57,18 +56,18 @@ export const showFilters: FilterDef = {
}, },
}; };
export const showSort = Sort( 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"], default: ["slug"],
tablePk: shows.pk,
}, },
); );
@ -159,7 +158,7 @@ export async function getShows({
after?: string; after?: string;
limit: number; limit: number;
query?: string; query?: string;
sort?: StaticDecode<typeof showSort>; sort?: Sort;
filter?: SQL; filter?: SQL;
languages: string[]; languages: string[];
fallbackLanguage?: boolean; fallbackLanguage?: boolean;
@ -209,13 +208,13 @@ export async function getShows({
and( and(
filter, filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined, query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ table: shows, after, sort }), keysetPaginate({ after, sort }),
), ),
) )
.orderBy( .orderBy(
...(query ...(query
? [sql`word_similarity(${query}::text, ${transQ.name})`] ? [sql`word_similarity(${query}::text, ${transQ.name})`]
: sortToSql(sort, shows)), : sortToSql(sort)),
shows.pk, shows.pk,
) )
.limit(limit); .limit(limit);

View File

@ -0,0 +1,465 @@
import { type SQL, and, eq, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { db } from "~/db";
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 type { MovieStatus } from "~/models/movie";
import { Role, Staff } from "~/models/staff";
import { RoleWShow, RoleWStaff } from "~/models/staff-roles";
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: staff.slug,
name: staff.name,
latinName: staff.latinName,
},
{
default: ["slug"],
tablePk: staff.pk,
},
);
const staffRoleSort = Sort(
{
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'`,
isNullable: true,
accessor: (x) => x.character.name,
},
characterLatinName: {
sql: sql`${roles.character}->>'latinName'`,
isNullable: true,
accessor: (x) => x.character.latinName,
},
},
{
default: ["order"],
tablePk: staff.pk,
},
);
const staffRoleFilter: FilterDef = {
kind: {
column: roles.kind,
type: "enum",
values: Role.properties.kind.enum,
},
};
const roleShowFilters: FilterDef = {
...staffRoleFilter,
...showFilters,
};
async function getStaffRoles({
after,
limit,
query,
sort,
filter,
}: {
after?: string;
limit: number;
query?: string;
sort?: Sort;
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({ sort, after }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${staff.name})`]
: sortToSql(sort)),
staff.pk,
)
.limit(limit);
}
export const staffH = new Elysia({ tags: ["staff"] })
.model({
staff: Staff,
role: Role,
})
.get(
"/staff/:id",
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: {
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/: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<MovieStatus>`${shows.status}`,
airDate: shows.startAir,
kind: sql<any>`${shows.kind}`,
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
...(preferOriginal && {
poster: sql<Image>`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`,
thumbnail: sql<Image>`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`,
banner: sql<Image>`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`,
logo: sql<Image>`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`,
}),
},
})
.from(roles)
.innerJoin(shows, eq(roles.showPk, shows.pk))
.innerJoin(transQ, eq(shows.pk, transQ.pk))
.where(
and(
eq(roles.staffPk, member.pk),
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${transQ.name})`]
: sortToSql(sort)),
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 } }) => {
const items = await db
.select()
.from(staff)
.where(
and(
query ? sql`${staff.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${staff.name})`]
: sortToSql(sort)),
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,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
}),
response: {
200: Page(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(
and(
eq(shows.kind, "movie"),
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 getStaffRoles({
limit,
after,
query,
sort,
filter: and(eq(roles.showPk, movie.pk), filter),
});
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,
},
},
)
.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(roles.showPk, serie.pk), 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,
},
},
);

View File

@ -1,4 +1,3 @@
import type { StaticDecode } from "@sinclair/typebox";
import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { type SQL, and, eq, exists, sql } from "drizzle-orm";
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import { db } from "~/db"; import { db } from "~/db";
@ -34,7 +33,16 @@ import {
import { desc } from "~/models/utils/descriptions"; import { desc } from "~/models/utils/descriptions";
import { getShows, showFilters, showSort } from "./shows/logic"; 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 = { const studioRelations = {
translations: () => { translations: () => {
@ -65,7 +73,7 @@ export async function getStudios({
after?: string; after?: string;
limit: number; limit: number;
query?: string; query?: string;
sort?: StaticDecode<typeof studioSort>; sort?: Sort;
filter?: SQL; filter?: SQL;
languages: string[]; languages: string[];
fallbackLanguage?: boolean; fallbackLanguage?: boolean;
@ -101,13 +109,13 @@ export async function getStudios({
and( and(
filter, filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined, query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ table: studios, after, sort }), keysetPaginate({ after, sort }),
), ),
) )
.orderBy( .orderBy(
...(query ...(query
? [sql`word_similarity(${query}::text, ${transQ.name})`] ? [sql`word_similarity(${query}::text, ${transQ.name})`]
: sortToSql(sort, studios)), : sortToSql(sort)),
studios.pk, studios.pk,
) )
.limit(limit); .limit(limit);
@ -138,7 +146,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
if (!ret) { if (!ret) {
return error(404, { return error(404, {
status: 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) { if (!ret.language) {
@ -156,7 +164,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
}, },
params: t.Object({ params: t.Object({
id: t.String({ 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", example: "mappa",
}), }),
}), }),
@ -173,7 +181,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
200: "studio", 200: "studio",
404: { 404: {
...KError, ...KError,
description: "No collection found with the given id or slug.", description: "No studio found with the given id or slug.",
}, },
422: KError, 422: KError,
}, },

View File

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

View File

@ -16,6 +16,7 @@ import {
import type { Image, Original } from "~/models/utils"; import type { Image, Original } from "~/models/utils";
import { entries } from "./entries"; import { entries } from "./entries";
import { seasons } from "./seasons"; import { seasons } from "./seasons";
import { roles } from "./staff";
import { showStudioJoin } from "./studios"; import { showStudioJoin } from "./studios";
import { externalid, image, language, schema } from "./utils"; import { externalid, image, language, schema } from "./utils";
@ -134,6 +135,7 @@ export const showsRelations = relations(shows, ({ many }) => ({
entries: many(entries, { relationName: "show_entries" }), entries: many(entries, { relationName: "show_entries" }),
seasons: many(seasons, { relationName: "show_seasons" }), seasons: many(seasons, { relationName: "show_seasons" }),
studios: many(showStudioJoin, { relationName: "ssj_show" }), studios: many(showStudioJoin, { relationName: "ssj_show" }),
staff: many(roles, { relationName: "show_roles" }),
})); }));
export const showsTrRelations = relations(showTranslations, ({ one }) => ({ export const showsTrRelations = relations(showTranslations, ({ one }) => ({
show: one(shows, { show: one(shows, {

View File

@ -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",
{
pk: integer().primaryKey().generatedAlwaysAsIdentity(),
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<Character>(),
},
(t) => [
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],
}),
}));

View File

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

View File

@ -64,6 +64,7 @@ app
`, `,
}, },
{ name: "studios", description: "Routes about studios" }, { name: "studios", description: "Routes about studios" },
{ name: "staff", description: "Routes about staff & roles" },
], ],
}, },
}), }),

View File

@ -5,7 +5,7 @@ export const duneCollection: SeedCollection = {
translations: { translations: {
en: { en: {
name: " Dune Collection", name: " Dune Collection",
tagline: "A mythic and emotionally charged hero's journey.", tagline: "A mythic and emotionally charged hero journey.",
description: description:
"The saga of Paul Atreides and his rise to power on the deadly planet Arrakis.", "The saga of Paul Atreides and his rise to power on the deadly planet Arrakis.",
aliases: [], aliases: [],

View File

@ -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; } satisfies SeedSerie;

View File

@ -2,6 +2,7 @@ import { t } from "elysia";
import type { Prettify } from "~/utils"; import type { Prettify } from "~/utils";
import { SeedCollection } from "./collections"; import { SeedCollection } from "./collections";
import { bubble, bubbleImages, registerExamples } from "./examples"; import { bubble, bubbleImages, registerExamples } from "./examples";
import { SeedStaff } from "./staff";
import { SeedStudio, Studio } from "./studio"; import { SeedStudio, Studio } from "./studio";
import { import {
DbMetadata, DbMetadata,
@ -90,6 +91,7 @@ export const SeedMovie = t.Intersect([
videos: t.Optional(t.Array(t.String({ format: "uuid" }), { default: [] })), videos: t.Optional(t.Array(t.String({ format: "uuid" }), { default: [] })),
collection: t.Optional(SeedCollection), collection: t.Optional(SeedCollection),
studios: t.Optional(t.Array(SeedStudio, { default: [] })), studios: t.Optional(t.Array(SeedStudio, { default: [] })),
staff: t.Optional(t.Array(SeedStaff, { default: [] })),
}), }),
]); ]);
export type SeedMovie = Prettify<typeof SeedMovie.static>; export type SeedMovie = Prettify<typeof SeedMovie.static>;

View File

@ -4,6 +4,7 @@ import { SeedCollection } from "./collections";
import { SeedEntry, SeedExtra } from "./entry"; import { SeedEntry, SeedExtra } from "./entry";
import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples";
import { SeedSeason } from "./season"; import { SeedSeason } from "./season";
import { SeedStaff } from "./staff";
import { SeedStudio, Studio } from "./studio"; import { SeedStudio, Studio } from "./studio";
import { import {
DbMetadata, DbMetadata,
@ -106,6 +107,7 @@ export const SeedSerie = t.Intersect([
extras: t.Optional(t.Array(SeedExtra, { default: [] })), extras: t.Optional(t.Array(SeedExtra, { default: [] })),
collection: t.Optional(SeedCollection), collection: t.Optional(SeedCollection),
studios: t.Optional(t.Array(SeedStudio, { default: [] })), studios: t.Optional(t.Array(SeedStudio, { default: [] })),
staff: t.Optional(t.Array(SeedStaff, { default: [] })),
}), }),
]); ]);
export type SeedSerie = typeof SeedSerie.static; export type SeedSerie = typeof SeedSerie.static;

View File

@ -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;

74
api/src/models/staff.ts Normal file
View File

@ -0,0 +1,74 @@
import { t } from "elysia";
import { bubbleImages, madeInAbyss, registerExamples } from "./examples";
import { DbMetadata, ExternalId, Image, Resource, SeedImage } from "./utils";
export const Character = t.Object({
name: t.String(),
latinName: t.Nullable(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;
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 SeedStaff = t.Intersect([
t.Omit(Role, ["character"]),
t.Object({
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 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,
},
},
});

View File

@ -1,7 +1,5 @@
import { type Column, and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
import type { NonEmptyArray, Sort } from "./sort"; import type { Sort } from "./sort";
type Table<Name extends string> = Record<Name, Column>;
type After = (string | number | boolean | undefined)[]; type After = (string | number | boolean | undefined)[];
@ -16,37 +14,37 @@ type After = (string | number | boolean | undefined)[];
// (x > a) OR // (x > a) OR
// (x = a AND y < b) OR // (x = a AND y < b) OR
// (x = a AND y = b AND z > c) OR... // (x = a AND y = b AND z > c) OR...
export const keysetPaginate = < export const keysetPaginate = ({
const T extends NonEmptyArray<string>,
const Remap extends Partial<Record<T[number], string>>,
>({
table,
sort, sort,
after, after,
}: { }: {
table: Table<"pk" | Sort<T, Remap>["sort"][number]["key"]>; sort: Sort | undefined;
after: string | undefined; after: string | undefined;
sort: Sort<T, Remap> | undefined;
}) => { }) => {
if (!after || !sort) return undefined; if (!after || !sort) return undefined;
const cursor: After = JSON.parse( const cursor: After = JSON.parse(
Buffer.from(after, "base64").toString("utf-8"), 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) { if (sort.random) {
return or( return or(
gt( gt(
sql`md5(${sort.random.seed} || ${table[pkSort.key]})`, sql`md5(${sort.random.seed} || ${sort.tablePk})`,
sql`md5(${sort.random.seed} || ${cursor[0]})`, sql`md5(${sort.random.seed} || ${cursor[0]})`,
), ),
and( and(
eq( eq(
sql`md5(${sort.random.seed} || ${table[pkSort.key]})`, sql`md5(${sort.random.seed} || ${sort.tablePk})`,
sql`md5(${sort.random.seed} || ${cursor[0]})`, 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, where,
and( and(
previous, previous,
or( or(cmp(by.sql, cursor[i]), by.isNullable ? isNull(by.sql) : undefined),
cmp(table[by.key], cursor[i]),
!table[by.key].notNull ? isNull(table[by.key]) : undefined,
),
), ),
); );
previous = and( previous = and(
previous, 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; return where;
}; };
export const generateAfter = < export const generateAfter = (cursor: any, sort: Sort) => {
const ST extends NonEmptyArray<string>, const ret = [...sort.sort.map((by) => by.accessor(cursor)), cursor.pk];
const Remap extends Partial<Record<ST[number], string>> = never,
>(
cursor: any,
sort: Sort<ST, Remap>,
) => {
const ret = [
...sort.sort.map((by) => cursor[by.remmapedKey ?? by.key]),
cursor.pk,
];
return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url");
}; };

View File

@ -1,7 +1,7 @@
import type { ObjectOptions } from "@sinclair/typebox"; import type { ObjectOptions } from "@sinclair/typebox";
import { type TSchema, t } from "elysia"; import { type TSchema, t } from "elysia";
import { generateAfter } from "./keyset-paginate"; import { generateAfter } from "./keyset-paginate";
import type { NonEmptyArray, Sort } from "./sort"; import type { Sort } from "./sort";
export const Page = <T extends TSchema>(schema: T, options?: ObjectOptions) => export const Page = <T extends TSchema>(schema: T, options?: ObjectOptions) =>
t.Object( t.Object(
@ -16,13 +16,9 @@ export const Page = <T extends TSchema>(schema: T, options?: ObjectOptions) =>
}, },
); );
export const createPage = < export const createPage = <T>(
T,
const ST extends NonEmptyArray<string>,
const Remap extends Partial<Record<ST[number], string>> = never,
>(
items: T[], items: T[],
{ url, sort, limit }: { url: string; sort: Sort<ST, Remap>; limit: number }, { url, sort, limit }: { url: string; sort: Sort; limit: number },
) => { ) => {
let next: string | null = null; let next: string | null = null;
const uri = new URL(url); const uri = new URL(url);

View File

@ -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 type { PgColumn } from "drizzle-orm/pg-core";
import { t } from "elysia"; import { t } from "elysia";
export type Sort< export type Sort = {
T extends string[], tablePk: SQLWrapper;
Remap extends Partial<Record<T[number], string>>,
> = {
sort: { sort: {
key: Exclude<T[number], keyof Remap> | NonNullable<Remap[keyof Remap]>; sql: SQLWrapper;
remmapedKey?: keyof Remap; isNullable: boolean;
accessor: (cursor: any) => unknown;
desc: boolean; desc: boolean;
}[]; }[];
random?: { seed: number }; random?: { seed: number };
}; };
export type NonEmptyArray<T> = [T, ...T[]]; export const Sort = (
values: Record<
export const Sort = < string,
const T extends NonEmptyArray<string>, | PgColumn
const Remap extends Partial<Record<T[number], string>> = never, | {
>( sql: PgColumn;
values: T, accessor: (cursor: any) => unknown;
}
| {
sql: SQLWrapper;
isNullable: boolean;
accessor: (cursor: any) => unknown;
}
>,
{ {
description = "How to sort the query", description = "How to sort the query",
default: def, default: def,
remap, tablePk,
}: { }: {
default?: T[number][]; default?: (keyof typeof values)[];
tablePk: SQLWrapper;
description?: string; description?: string;
remap?: Remap;
}, },
) => ) =>
t t
@ -36,35 +42,48 @@ export const Sort = <
t.Array( t.Array(
t.Union([ t.Union([
t.UnionEnum([ t.UnionEnum([
...values, ...Object.keys(values),
...values.map((x: T[number]) => `-${x}` as const), ...Object.keys(values).map((x) => `-${x}`),
"random", "random",
]), ] as any),
t.TemplateLiteral("random:${number}"), t.TemplateLiteral("random:${number}"),
]), ]),
{ {
// TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia
explode: false,
default: def, default: def,
description: description, description: description,
}, },
), ),
) )
.Decode((sort): Sort<T, Remap> => { .Decode((sort: string[]): Sort => {
const random = sort.find((x) => x.startsWith("random")); const random = sort.find((x) => x.startsWith("random"));
if (random) { if (random) {
const seed = random.includes(":") const seed = random.includes(":")
? Number.parseInt(random.substring("random:".length)) ? Number.parseInt(random.substring("random:".length))
: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); : Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
return { random: { seed }, sort: [] }; return { tablePk, random: { seed }, sort: [] };
} }
return { return {
tablePk,
sort: sort.map((x) => { sort: sort.map((x) => {
const desc = x[0] === "-"; const desc = x[0] === "-";
const key = (desc ? x.substring(1) : x) as T[number]; const key = desc ? x.substring(1) : x;
if (remap && key in remap) if ("getSQL" in values[key]) {
return { key: remap[key]!, remmapedKey: key, desc }; return {
return { key: key as Exclude<typeof key, keyof Remap>, desc }; 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,
};
}), }),
}; };
}) })
@ -72,20 +91,12 @@ export const Sort = <
throw new Error("Encode not supported for sort"); throw new Error("Encode not supported for sort");
}); });
type Table<Name extends string> = Record<Name, PgColumn>; export const sortToSql = (sort: Sort | undefined) => {
export const sortToSql = <
T extends string[],
Remap extends Partial<Record<T[number], string>>,
>(
sort: Sort<T, Remap> | undefined,
table: Table<Sort<T, Remap>["sort"][number]["key"] | "pk">,
) => {
if (!sort) return []; if (!sort) return [];
if (sort.random) { if (sort.random) {
return [sql`md5(${sort.random.seed} || ${table.pk})`]; return [sql`md5(${sort.random.seed} || ${sort.tablePk})`];
} }
return sort.sort.map((x) => 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),
); );
}; };

View File

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

View File

@ -0,0 +1,77 @@
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;
};
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;
};

View File

@ -0,0 +1,54 @@
import { beforeAll, describe, expect, it } from "bun:test";
import {
createSerie,
getSerieStaff,
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);
});
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);
});
});