Add GET /series (using the same logic as /movies)

This commit is contained in:
Zoe Roux 2025-03-02 18:09:25 +01:00
parent 24f44de7c0
commit cc221c560d
No known key found for this signature in database
4 changed files with 196 additions and 108 deletions

View File

@ -2,49 +2,25 @@ import { type SQL, and, eq, exists, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { db } from "~/db";
import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema";
import { getColumns, sqlarr } from "~/db/utils";
import { sqlarr } from "~/db/utils";
import { KError } from "~/models/error";
import { bubble } from "~/models/examples";
import {
FullMovie,
Movie,
MovieStatus,
type MovieStatus,
MovieTranslation,
} from "~/models/movie";
import {
AcceptLanguage,
Filter,
type FilterDef,
Genre,
type Image,
Page,
Sort,
createPage,
isUuid,
keysetPaginate,
processLanguages,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
const movieFilters: FilterDef = {
genres: {
column: shows.genres,
type: "enum",
values: Genre.enum,
isArray: true,
},
rating: { column: shows.rating, type: "int" },
status: { column: shows.status, type: "enum", values: MovieStatus.enum },
runtime: { column: shows.runtime, type: "float" },
airDate: { column: shows.startAir, type: "date" },
originalLanguage: { column: shows.originalLanguage, type: "string" },
tags: {
column: sql.raw(`t.${showTranslations.tags.name}`),
type: "string",
isArray: true,
},
};
import { getShows, showFilters, showSort } from "./shows";
export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
.model({
@ -236,85 +212,22 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
request: { url },
}) => {
const langs = processLanguages(languages);
// we keep the pk for after handling. it will be removed by elysia's validators after.
const { kind, startAir, endAir, ...moviesCol } = getColumns(shows);
const transQ = db
.selectDistinctOn([showTranslations.pk])
.from(showTranslations)
.orderBy(
showTranslations.pk,
sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`,
)
.as("t");
const { pk, poster, thumbnail, banner, logo, ...transCol } =
getColumns(transQ);
const videoQ = db
.select({ showPk: entries.showPk })
.from(entries)
.where(
exists(
db
.select()
.from(entryVideoJoin)
.where(eq(entries.pk, entryVideoJoin.entry)),
),
)
.as("video");
const items = await db
.select({
...moviesCol,
...transCol,
status: sql<MovieStatus>`${moviesCol.status}`,
airDate: startAir,
poster: sql<Image>`coalesce(${showTranslations.poster}, ${poster})`,
thumbnail: sql<Image>`coalesce(${showTranslations.thumbnail}, ${thumbnail})`,
banner: sql<Image>`coalesce(${showTranslations.banner}, ${banner})`,
logo: sql<Image>`coalesce(${showTranslations.logo}, ${logo})`,
isAvailable: sql<boolean>`${videoQ.showPk} is not null`.as(
"isAvailable",
),
})
.from(shows)
.innerJoin(transQ, eq(shows.pk, transQ.pk))
.leftJoin(
showTranslations,
and(
eq(shows.pk, showTranslations.pk),
eq(showTranslations.language, shows.originalLanguage),
// TODO: check user's settings before fallbacking to false.
sql`coalesce(${preferOriginal ?? null}::boolean, false)`,
),
)
.leftJoin(videoQ, eq(shows.pk, videoQ.showPk))
.where(
and(
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)),
shows.pk,
)
.limit(limit);
const items = await getShows({
limit,
after,
query,
sort,
filter: and(eq(shows.kind, "movie"), filter),
languages: langs,
preferOriginal,
});
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get all movies" },
query: t.Object({
sort: Sort(["slug", "rating", "airDate", "createdAt", "nextRefresh"], {
remap: { airDate: "startAir" },
default: ["slug"],
}),
filter: t.Optional(Filter({ def: movieFilters })),
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,

View File

@ -1,11 +1,67 @@
import { and, eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { Serie } from "~/models/serie";
import { shows } from "~/db/schema";
import { KError } from "~/models/error";
import { Serie, SerieTranslation } from "~/models/serie";
import {
AcceptLanguage,
Filter,
Page,
createPage,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
import { getShows, showFilters, showSort } from "./shows";
export const series = new Elysia({ prefix: "/series" })
export const series = new Elysia({ prefix: "/series", tags: ["series"] })
.model({
serie: Serie,
error: t.Object({}),
"serie-translation": SerieTranslation,
})
.get("/:id", () => "hello" as unknown as Serie, {
response: { 200: "serie" },
});
.get(
"",
async ({
query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages },
request: { url },
}) => {
const langs = processLanguages(languages);
const items = await getShows({
limit,
after,
query,
sort,
filter: and(eq(shows.kind, "serie"), filter),
languages: langs,
preferOriginal,
});
return createPage(items, { url, sort, limit });
},
{
detail: { description: "Get all series" },
query: t.Object({
sort: showSort,
filter: t.Optional(Filter({ def: showFilters })),
query: t.Optional(t.String({ description: desc.query })),
limit: t.Integer({
minimum: 1,
maximum: 250,
default: 50,
description: "Max page size.",
}),
after: t.Optional(t.String({ description: desc.after })),
preferOriginal: t.Optional(
t.Boolean({
description: desc.preferOriginal,
}),
),
}),
headers: t.Object({
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Serie),
422: KError,
},
},
);

View File

@ -0,0 +1,119 @@
import type { StaticDecode } from "@sinclair/typebox";
import { type SQL, and, eq, sql } from "drizzle-orm";
import { db } from "~/db";
import { showTranslations, shows } from "~/db/schema";
import { getColumns, sqlarr } from "~/db/utils";
import type { MovieStatus } from "~/models/movie";
import { SerieStatus } from "~/models/serie";
import {
type FilterDef,
Genre,
type Image,
Sort,
keysetPaginate,
sortToSql,
} from "~/models/utils";
export const showFilters: FilterDef = {
genres: {
column: shows.genres,
type: "enum",
values: Genre.enum,
isArray: true,
},
rating: { column: shows.rating, type: "int" },
status: { column: shows.status, type: "enum", values: SerieStatus.enum },
runtime: { column: shows.runtime, type: "float" },
airDate: { column: shows.startAir, type: "date" },
startAir: { column: shows.startAir, type: "date" },
endAir: { column: shows.startAir, type: "date" },
originalLanguage: { column: shows.originalLanguage, type: "string" },
tags: {
column: sql.raw(`t.${showTranslations.tags.name}`),
type: "string",
isArray: true,
},
};
export const showSort = Sort(
[
"slug",
"rating",
"airDate",
"startAir",
"endAir",
"createdAt",
"nextRefresh",
],
{
remap: { airDate: "startAir" },
default: ["slug"],
},
);
export async function getShows({
after,
limit,
query,
sort,
filter,
languages,
preferOriginal,
}: {
after: string | undefined;
limit: number;
query: string | undefined;
sort: StaticDecode<typeof showSort>;
filter: SQL | undefined;
languages: string[];
preferOriginal: boolean | undefined;
}) {
const transQ = db
.selectDistinctOn([showTranslations.pk])
.from(showTranslations)
.orderBy(
showTranslations.pk,
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
)
.as("t");
const { pk, poster, thumbnail, banner, logo, ...transCol } =
getColumns(transQ);
return await db
.select({
...getColumns(shows),
...transCol,
// movie columns (status is only a typescript hint)
status: sql<MovieStatus>`${shows.status}`,
airDate: shows.startAir,
poster: sql<Image>`coalesce(${showTranslations.poster}, ${poster})`,
thumbnail: sql<Image>`coalesce(${showTranslations.thumbnail}, ${thumbnail})`,
banner: sql<Image>`coalesce(${showTranslations.banner}, ${banner})`,
logo: sql<Image>`coalesce(${showTranslations.logo}, ${logo})`,
})
.from(shows)
.innerJoin(transQ, eq(shows.pk, transQ.pk))
.leftJoin(
showTranslations,
and(
eq(shows.pk, showTranslations.pk),
eq(showTranslations.language, shows.originalLanguage),
// TODO: check user's settings before fallbacking to false.
sql`coalesce(${preferOriginal ?? null}::boolean, false)`,
),
)
.where(
and(
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)),
shows.pk,
)
.limit(limit);
}

View File

@ -57,7 +57,7 @@ export const Movie = t.Intersect([
Resource(),
MovieTranslation,
BaseMovie,
t.Object({ isAvailable: t.Boolean() }),
// t.Object({ isAvailable: t.Boolean() }),
]);
export type Movie = Prettify<typeof Movie.static>;