Kyoo/api/src/controllers/staff.ts
2026-01-05 12:51:39 +01:00

496 lines
11 KiB
TypeScript

import { and, eq, type SQL, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base";
import { db } from "~/db";
import { profiles, shows, showTranslations } from "~/db/schema";
import { roles, staff } from "~/db/schema/staff";
import { watchlist } from "~/db/schema/watchlist";
import { getColumns, jsonbBuildObject, 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 {
AcceptLanguage,
createPage,
Filter,
type FilterDef,
type Image,
isUuid,
keysetPaginate,
Page,
processLanguages,
Sort,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
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}) desc`]
: sortToSql(sort)),
staff.pk,
)
.limit(limit);
}
export const staffH = new Elysia({ tags: ["staff"] })
.model({
staff: Staff,
role: Role,
})
.use(auth)
.get(
"/staff/:id",
async ({ params: { id }, status }) => {
const [ret] = await db
.select()
.from(staff)
.where(isUuid(id) ? eq(staff.id, id) : eq(staff.slug, id))
.limit(1);
if (!ret) {
return status(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 ({ status, redirect }) => {
const [member] = await db
.select({ slug: staff.slug })
.from(staff)
.orderBy(sql`random()`)
.limit(1);
if (!member)
return status(404, {
status: 404,
message: "No staff in the database.",
});
return redirect(`${prefix}/staff/${member.slug}`);
},
{
detail: {
description: "Get a random staff member.",
},
response: {
302: t.Void({
description:
"Redirected to the [/staff/{id}](#tag/staff/get/api/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, ...headers },
request: { url },
jwt: { sub, settings },
status,
}) => {
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 status(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 watchStatusQ = db
.select({
watchStatus: jsonbBuildObject<MovieWatchStatus & SerieWatchStatus>({
...getColumns(watchlist),
percent: watchlist.seenCount,
}).as("watchStatus"),
})
.from(watchlist)
.leftJoin(profiles, eq(watchlist.profilePk, profiles.pk))
.where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk)))
.as("watchstatus");
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 ?? settings.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})`,
}),
watchStatus: sql`${watchStatusQ}`,
},
})
.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}) desc`]
: sortToSql(sort)),
roles.showPk,
)
.limit(limit);
return createPage(items, { url, sort, limit, headers });
},
{
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,
}),
),
}),
headers: t.Object({
"accept-language": AcceptLanguage(),
}),
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 },
headers,
}) => {
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}) desc`]
: sortToSql(sort)),
staff.pk,
)
.limit(limit);
return createPage(items, { url, sort, limit, headers });
},
{
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 },
headers,
status,
}) => {
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 status(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, headers });
},
{
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 },
headers,
status,
}) => {
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 status(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, headers });
},
{
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,
},
},
);