mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Add original name & latinName in series/movie (#833)
This commit is contained in:
commit
e0ad458d73
3
api/drizzle/0013_original.sql
Normal file
3
api/drizzle/0013_original.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TABLE "kyoo"."videos" ALTER COLUMN "guess" DROP DEFAULT;--> statement-breakpoint
|
||||
ALTER TABLE "kyoo"."shows" ADD COLUMN "original" jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "kyoo"."shows" DROP COLUMN "original_language";
|
1277
api/drizzle/meta/0013_snapshot.json
Normal file
1277
api/drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,13 @@
|
||||
"when": 1741360992371,
|
||||
"tag": "0012_available_count",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1741444868735,
|
||||
"tag": "0013_original",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -9,7 +9,13 @@ import {
|
||||
shows,
|
||||
videos,
|
||||
} from "~/db/schema";
|
||||
import { getColumns, sqlarr } from "~/db/utils";
|
||||
import {
|
||||
coalesce,
|
||||
getColumns,
|
||||
jsonbAgg,
|
||||
jsonbBuildObject,
|
||||
sqlarr,
|
||||
} from "~/db/utils";
|
||||
import {
|
||||
Entry,
|
||||
type EntryKind,
|
||||
@ -35,6 +41,7 @@ import {
|
||||
sortToSql,
|
||||
} from "~/models/utils";
|
||||
import { desc } from "~/models/utils/descriptions";
|
||||
import type { EmbeddedVideo } from "~/models/video";
|
||||
|
||||
const entryFilters: FilterDef = {
|
||||
kind: {
|
||||
@ -107,17 +114,21 @@ async function getEntries({
|
||||
|
||||
const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos);
|
||||
const videosQ = db
|
||||
.select({ slug: entryVideoJoin.slug, ...videosCol })
|
||||
.select({
|
||||
videos: coalesce(
|
||||
jsonbAgg(
|
||||
jsonbBuildObject<EmbeddedVideo>({
|
||||
slug: entryVideoJoin.slug,
|
||||
...videosCol,
|
||||
}),
|
||||
),
|
||||
sql`'[]'::jsonb`,
|
||||
).as("videos"),
|
||||
})
|
||||
.from(entryVideoJoin)
|
||||
.where(eq(entryVideoJoin.entryPk, entries.pk))
|
||||
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.as("videos");
|
||||
const videosJ = db
|
||||
.select({
|
||||
videos: sql`coalesce(json_agg("videos"), '[]'::json)`.as("videos"),
|
||||
})
|
||||
.from(videosQ)
|
||||
.as("videos_json");
|
||||
|
||||
const {
|
||||
kind,
|
||||
@ -132,7 +143,7 @@ async function getEntries({
|
||||
.select({
|
||||
...entryCol,
|
||||
...transCol,
|
||||
videos: videosJ.videos,
|
||||
videos: videosQ.videos,
|
||||
// specials don't have an `episodeNumber` but a `number` field.
|
||||
number: episodeNumber,
|
||||
|
||||
@ -150,7 +161,7 @@ async function getEntries({
|
||||
})
|
||||
.from(entries)
|
||||
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
||||
.leftJoinLateral(videosJ, sql`true`)
|
||||
.leftJoinLateral(videosQ, sql`true`)
|
||||
.where(
|
||||
and(
|
||||
filter,
|
||||
|
@ -28,6 +28,7 @@ export const insertCollection = async (
|
||||
endAir: show.kind === "movie" ? show.airDate : show.endAir,
|
||||
nextRefresh: show.nextRefresh,
|
||||
entriesCount: 0,
|
||||
original: {} as any,
|
||||
...col,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { t } from "elysia";
|
||||
import type { SeedMovie } from "~/models/movie";
|
||||
import { getYear } from "~/utils";
|
||||
import { processOptImage } from "./images";
|
||||
import { insertCollection } from "./insert/collection";
|
||||
import { insertEntries } from "./insert/entries";
|
||||
import { insertShow, updateAvailableCount } from "./insert/shows";
|
||||
@ -45,8 +46,15 @@ export const seedMovie = async (
|
||||
seed.slug = `random-${getYear(seed.airDate)}`;
|
||||
}
|
||||
|
||||
const { translations, videos, collection, studios, ...bMovie } = seed;
|
||||
const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date());
|
||||
const { translations, videos, collection, studios, ...movie } = seed;
|
||||
const nextRefresh = guessNextRefresh(movie.airDate ?? new Date());
|
||||
const original = translations[movie.originalLanguage];
|
||||
if (!original) {
|
||||
return {
|
||||
status: 422,
|
||||
message: "No translation available in the original language.",
|
||||
};
|
||||
}
|
||||
|
||||
const col = await insertCollection(collection, {
|
||||
kind: "movie",
|
||||
@ -57,11 +65,20 @@ export const seedMovie = async (
|
||||
const show = await insertShow(
|
||||
{
|
||||
kind: "movie",
|
||||
startAir: bMovie.airDate,
|
||||
startAir: movie.airDate,
|
||||
nextRefresh,
|
||||
collectionPk: col?.pk,
|
||||
entriesCount: 1,
|
||||
...bMovie,
|
||||
original: {
|
||||
language: movie.originalLanguage,
|
||||
name: original.name,
|
||||
latinName: original.latinName ?? null,
|
||||
poster: processOptImage(original.poster),
|
||||
thumbnail: processOptImage(original.thumbnail),
|
||||
logo: processOptImage(original.logo),
|
||||
banner: processOptImage(original.banner),
|
||||
},
|
||||
...movie,
|
||||
},
|
||||
translations,
|
||||
);
|
||||
@ -70,11 +87,11 @@ export const seedMovie = async (
|
||||
// even if never shown to the user, a movie still has an entry.
|
||||
const [entry] = await insertEntries(show, [
|
||||
{
|
||||
...bMovie,
|
||||
...movie,
|
||||
kind: "movie",
|
||||
order: 1,
|
||||
thumbnail: (bMovie.originalLanguage
|
||||
? translations[bMovie.originalLanguage]
|
||||
thumbnail: (movie.originalLanguage
|
||||
? translations[movie.originalLanguage]
|
||||
: Object.values(translations)[0]
|
||||
)?.thumbnail,
|
||||
translations,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { t } from "elysia";
|
||||
import type { SeedSerie } from "~/models/serie";
|
||||
import { getYear } from "~/utils";
|
||||
import { processOptImage } from "./images";
|
||||
import { insertCollection } from "./insert/collection";
|
||||
import { insertEntries } from "./insert/entries";
|
||||
import { insertSeasons } from "./insert/seasons";
|
||||
@ -82,6 +83,13 @@ export const seedSerie = async (
|
||||
...serie
|
||||
} = seed;
|
||||
const nextRefresh = guessNextRefresh(serie.startAir ?? new Date());
|
||||
const original = translations[serie.originalLanguage];
|
||||
if (!original) {
|
||||
return {
|
||||
status: 422,
|
||||
message: "No translation available in the original language.",
|
||||
};
|
||||
}
|
||||
|
||||
const col = await insertCollection(collection, {
|
||||
kind: "serie",
|
||||
@ -95,6 +103,15 @@ export const seedSerie = async (
|
||||
nextRefresh,
|
||||
collectionPk: col?.pk,
|
||||
entriesCount: entries.length,
|
||||
original: {
|
||||
language: serie.originalLanguage,
|
||||
name: original.name,
|
||||
latinName: original.latinName ?? null,
|
||||
poster: processOptImage(original.poster),
|
||||
thumbnail: processOptImage(original.thumbnail),
|
||||
logo: processOptImage(original.logo),
|
||||
banner: processOptImage(original.banner),
|
||||
},
|
||||
...serie,
|
||||
},
|
||||
translations,
|
||||
|
@ -21,7 +21,7 @@ import {
|
||||
processLanguages,
|
||||
} from "~/models/utils";
|
||||
import { desc } from "~/models/utils/descriptions";
|
||||
import { getShow, getShows, showFilters, showSort } from "./logic";
|
||||
import { getShows, showFilters, showSort } from "./logic";
|
||||
|
||||
export const collections = new Elysia({
|
||||
prefix: "/collections",
|
||||
@ -41,11 +41,16 @@ export const collections = new Elysia({
|
||||
set,
|
||||
}) => {
|
||||
const langs = processLanguages(languages);
|
||||
const ret = await getShow(id, {
|
||||
const [ret] = await getShows({
|
||||
limit: 1,
|
||||
filter: and(
|
||||
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
|
||||
eq(shows.kind, "collection"),
|
||||
),
|
||||
languages: langs,
|
||||
fallbackLanguage: langs.includes("*"),
|
||||
preferOriginal,
|
||||
relations,
|
||||
filters: eq(shows.kind, "collection"),
|
||||
});
|
||||
if (!ret) {
|
||||
return error(404, {
|
||||
@ -60,7 +65,7 @@ export const collections = new Elysia({
|
||||
});
|
||||
}
|
||||
set.headers["content-language"] = ret.language;
|
||||
return ret.show;
|
||||
return ret;
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
|
@ -1,18 +1,31 @@
|
||||
import type { StaticDecode } from "@sinclair/typebox";
|
||||
import { type SQL, and, eq, sql } from "drizzle-orm";
|
||||
import { type SQL, and, eq, exists, sql } from "drizzle-orm";
|
||||
import { db } from "~/db";
|
||||
import { showTranslations, shows, studioTranslations } from "~/db/schema";
|
||||
import { getColumns, sqlarr } from "~/db/utils";
|
||||
import {
|
||||
showStudioJoin,
|
||||
showTranslations,
|
||||
shows,
|
||||
studioTranslations,
|
||||
studios,
|
||||
} from "~/db/schema";
|
||||
import {
|
||||
coalesce,
|
||||
getColumns,
|
||||
jsonbAgg,
|
||||
jsonbBuildObject,
|
||||
jsonbObjectAgg,
|
||||
sqlarr,
|
||||
} from "~/db/utils";
|
||||
import type { MovieStatus } from "~/models/movie";
|
||||
import { SerieStatus } from "~/models/serie";
|
||||
import { SerieStatus, type SerieTranslation } from "~/models/serie";
|
||||
import type { Studio } from "~/models/studio";
|
||||
import {
|
||||
type FilterDef,
|
||||
Genre,
|
||||
type Image,
|
||||
Sort,
|
||||
isUuid,
|
||||
buildRelations,
|
||||
keysetPaginate,
|
||||
selectTranslationQuery,
|
||||
sortToSql,
|
||||
} from "~/models/utils";
|
||||
|
||||
@ -29,7 +42,10 @@ export const showFilters: FilterDef = {
|
||||
airDate: { column: shows.startAir, type: "date" },
|
||||
startAir: { column: shows.startAir, type: "date" },
|
||||
endAir: { column: shows.startAir, type: "date" },
|
||||
originalLanguage: { column: shows.originalLanguage, type: "string" },
|
||||
originalLanguage: {
|
||||
column: sql`${shows.original}->'language'`,
|
||||
type: "string",
|
||||
},
|
||||
tags: {
|
||||
column: sql.raw(`t.${showTranslations.tags.name}`),
|
||||
type: "string",
|
||||
@ -52,6 +68,62 @@ export const showSort = Sort(
|
||||
},
|
||||
);
|
||||
|
||||
const showRelations = {
|
||||
translations: () => {
|
||||
const { pk, language, ...trans } = getColumns(showTranslations);
|
||||
return db
|
||||
.select({
|
||||
json: jsonbObjectAgg(
|
||||
language,
|
||||
jsonbBuildObject<SerieTranslation>(trans),
|
||||
).as("json"),
|
||||
})
|
||||
.from(showTranslations)
|
||||
.where(eq(showTranslations.pk, shows.pk))
|
||||
.as("translations");
|
||||
},
|
||||
studios: ({ languages }: { languages: string[] }) => {
|
||||
const { pk: _, ...studioCol } = getColumns(studios);
|
||||
const studioTransQ = db
|
||||
.selectDistinctOn([studioTranslations.pk])
|
||||
.from(studioTranslations)
|
||||
.orderBy(
|
||||
studioTranslations.pk,
|
||||
sql`array_position(${sqlarr(languages)}, ${studioTranslations.language})`,
|
||||
)
|
||||
.as("t");
|
||||
const { pk, language, ...studioTrans } = getColumns(studioTransQ);
|
||||
|
||||
return db
|
||||
.select({
|
||||
json: coalesce(
|
||||
jsonbAgg(jsonbBuildObject<Studio>({ ...studioTrans, ...studioCol })),
|
||||
sql`'[]'::jsonb`,
|
||||
).as("json"),
|
||||
})
|
||||
.from(studios)
|
||||
.leftJoin(studioTransQ, eq(studios.pk, studioTransQ.pk))
|
||||
.where(
|
||||
exists(
|
||||
db
|
||||
.select()
|
||||
.from(showStudioJoin)
|
||||
.where(
|
||||
and(
|
||||
eq(showStudioJoin.studioPk, studios.pk),
|
||||
eq(showStudioJoin.showPk, shows.pk),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.as("studios");
|
||||
},
|
||||
// only available for movies
|
||||
videos: () => {
|
||||
throw new Error();
|
||||
},
|
||||
};
|
||||
|
||||
export async function getShows({
|
||||
after,
|
||||
limit,
|
||||
@ -59,51 +131,58 @@ export async function getShows({
|
||||
sort,
|
||||
filter,
|
||||
languages,
|
||||
preferOriginal,
|
||||
fallbackLanguage = true,
|
||||
preferOriginal = false,
|
||||
relations = [],
|
||||
}: {
|
||||
after: string | undefined;
|
||||
after?: string;
|
||||
limit: number;
|
||||
query: string | undefined;
|
||||
sort: StaticDecode<typeof showSort>;
|
||||
filter: SQL | undefined;
|
||||
query?: string;
|
||||
sort?: StaticDecode<typeof showSort>;
|
||||
filter?: SQL;
|
||||
languages: string[];
|
||||
preferOriginal: boolean | undefined;
|
||||
fallbackLanguage?: boolean;
|
||||
preferOriginal?: boolean;
|
||||
relations?: (keyof typeof showRelations)[];
|
||||
}) {
|
||||
const transQ = db
|
||||
.selectDistinctOn([showTranslations.pk])
|
||||
.from(showTranslations)
|
||||
.where(
|
||||
!fallbackLanguage
|
||||
? eq(showTranslations.language, sql`any(${sqlarr(languages)})`)
|
||||
: undefined,
|
||||
)
|
||||
.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,
|
||||
...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`,
|
||||
|
||||
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})`,
|
||||
...(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})`,
|
||||
}),
|
||||
|
||||
...buildRelations(relations, showRelations, { languages }),
|
||||
})
|
||||
.from(shows)
|
||||
.innerJoin(transQ, eq(shows.pk, transQ.pk))
|
||||
.leftJoin(
|
||||
showTranslations,
|
||||
and(
|
||||
sql`${preferOriginal ?? false}`,
|
||||
eq(shows.pk, showTranslations.pk),
|
||||
eq(showTranslations.language, shows.originalLanguage),
|
||||
),
|
||||
[fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")](
|
||||
transQ,
|
||||
eq(shows.pk, transQ.pk),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
@ -120,93 +199,3 @@ export async function getShows({
|
||||
)
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export async function getShow(
|
||||
id: string,
|
||||
{
|
||||
languages,
|
||||
preferOriginal,
|
||||
relations,
|
||||
filters,
|
||||
}: {
|
||||
languages: string[];
|
||||
preferOriginal: boolean | undefined;
|
||||
relations: ("translations" | "studios" | "videos")[];
|
||||
filters: SQL | undefined;
|
||||
},
|
||||
) {
|
||||
const ret = await db.query.shows.findFirst({
|
||||
extras: {
|
||||
airDate: sql<string>`${shows.startAir}`.as("airDate"),
|
||||
status: sql<MovieStatus>`${shows.status}`.as("status"),
|
||||
isAvailable: sql<boolean>`${shows.availableCount} != 0`.as("isAvailable"),
|
||||
},
|
||||
where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters),
|
||||
with: {
|
||||
selectedTranslation: selectTranslationQuery(showTranslations, languages),
|
||||
...(preferOriginal && {
|
||||
originalTranslation: {
|
||||
columns: {
|
||||
poster: true,
|
||||
thumbnail: true,
|
||||
banner: true,
|
||||
logo: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(relations.includes("translations") && {
|
||||
translations: {
|
||||
columns: {
|
||||
pk: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(relations.includes("studios") && {
|
||||
studios: {
|
||||
with: {
|
||||
studio: {
|
||||
columns: {
|
||||
pk: false,
|
||||
},
|
||||
with: {
|
||||
selectedTranslation: selectTranslationQuery(
|
||||
studioTranslations,
|
||||
languages,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (!ret) return null;
|
||||
const translation = ret.selectedTranslation[0];
|
||||
if (!translation) return { show: null, language: null };
|
||||
const ot = ret.originalTranslation;
|
||||
const show = {
|
||||
...ret,
|
||||
...translation,
|
||||
kind: ret.kind as any,
|
||||
...(ot && {
|
||||
...(ot.poster && { poster: ot.poster }),
|
||||
...(ot.thumbnail && { thumbnail: ot.thumbnail }),
|
||||
...(ot.banner && { banner: ot.banner }),
|
||||
...(ot.logo && { logo: ot.logo }),
|
||||
}),
|
||||
...(ret.translations && {
|
||||
translations: Object.fromEntries(
|
||||
ret.translations.map(
|
||||
({ language, ...translation }) => [language, translation] as const,
|
||||
),
|
||||
),
|
||||
}),
|
||||
...(ret.studios && {
|
||||
studios: ret.studios.map((x: any) => ({
|
||||
...x.studio,
|
||||
...x.studio.selectedTranslation[0],
|
||||
})),
|
||||
}),
|
||||
};
|
||||
return { show, language: translation.language };
|
||||
}
|
||||
|
@ -10,10 +10,11 @@ import {
|
||||
Filter,
|
||||
Page,
|
||||
createPage,
|
||||
isUuid,
|
||||
processLanguages,
|
||||
} from "~/models/utils";
|
||||
import { desc } from "~/models/utils/descriptions";
|
||||
import { getShow, getShows, showFilters, showSort } from "./logic";
|
||||
import { getShows, showFilters, showSort } from "./logic";
|
||||
|
||||
export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
||||
.model({
|
||||
@ -30,11 +31,16 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
||||
set,
|
||||
}) => {
|
||||
const langs = processLanguages(languages);
|
||||
const ret = await getShow(id, {
|
||||
const [ret] = await getShows({
|
||||
limit: 1,
|
||||
filter: and(
|
||||
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
|
||||
eq(shows.kind, "movie"),
|
||||
),
|
||||
languages: langs,
|
||||
fallbackLanguage: langs.includes("*"),
|
||||
preferOriginal,
|
||||
relations,
|
||||
filters: eq(shows.kind, "movie"),
|
||||
});
|
||||
if (!ret) {
|
||||
return error(404, {
|
||||
@ -49,7 +55,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
||||
});
|
||||
}
|
||||
set.headers["content-language"] = ret.language;
|
||||
return ret.show;
|
||||
return ret;
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
|
@ -10,10 +10,11 @@ import {
|
||||
Filter,
|
||||
Page,
|
||||
createPage,
|
||||
isUuid,
|
||||
processLanguages,
|
||||
} from "~/models/utils";
|
||||
import { desc } from "~/models/utils/descriptions";
|
||||
import { getShow, getShows, showFilters, showSort } from "./logic";
|
||||
import { getShows, showFilters, showSort } from "./logic";
|
||||
|
||||
export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
||||
.model({
|
||||
@ -30,11 +31,16 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
||||
set,
|
||||
}) => {
|
||||
const langs = processLanguages(languages);
|
||||
const ret = await getShow(id, {
|
||||
const [ret] = await getShows({
|
||||
limit: 1,
|
||||
filter: and(
|
||||
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
|
||||
eq(shows.kind, "serie"),
|
||||
),
|
||||
languages: langs,
|
||||
fallbackLanguage: langs.includes("*"),
|
||||
preferOriginal,
|
||||
relations,
|
||||
filters: eq(shows.kind, "serie"),
|
||||
});
|
||||
if (!ret) {
|
||||
return error(404, {
|
||||
@ -49,7 +55,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
||||
});
|
||||
}
|
||||
set.headers["content-language"] = ret.language;
|
||||
return ret.show;
|
||||
return ret;
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { and, eq, exists, sql } from "drizzle-orm";
|
||||
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";
|
||||
import {
|
||||
@ -7,7 +8,12 @@ import {
|
||||
studioTranslations,
|
||||
studios,
|
||||
} from "~/db/schema";
|
||||
import { getColumns, sqlarr } from "~/db/utils";
|
||||
import {
|
||||
getColumns,
|
||||
jsonbBuildObject,
|
||||
jsonbObjectAgg,
|
||||
sqlarr,
|
||||
} from "~/db/utils";
|
||||
import { KError } from "~/models/error";
|
||||
import { Movie } from "~/models/movie";
|
||||
import { Serie } from "~/models/serie";
|
||||
@ -18,11 +24,11 @@ import {
|
||||
Filter,
|
||||
Page,
|
||||
Sort,
|
||||
buildRelations,
|
||||
createPage,
|
||||
isUuid,
|
||||
keysetPaginate,
|
||||
processLanguages,
|
||||
selectTranslationQuery,
|
||||
sortToSql,
|
||||
} from "~/models/utils";
|
||||
import { desc } from "~/models/utils/descriptions";
|
||||
@ -30,6 +36,83 @@ import { getShows, showFilters, showSort } from "./shows/logic";
|
||||
|
||||
const studioSort = Sort(["slug", "createdAt"], { default: ["slug"] });
|
||||
|
||||
const studioRelations = {
|
||||
translations: () => {
|
||||
const { pk, language, ...trans } = getColumns(studioTranslations);
|
||||
return db
|
||||
.select({
|
||||
json: jsonbObjectAgg(
|
||||
language,
|
||||
jsonbBuildObject<StudioTranslation>(trans),
|
||||
).as("json"),
|
||||
})
|
||||
.from(studioTranslations)
|
||||
.where(eq(studioTranslations.pk, shows.pk))
|
||||
.as("translations");
|
||||
},
|
||||
};
|
||||
|
||||
export async function getStudios({
|
||||
after,
|
||||
limit,
|
||||
query,
|
||||
sort,
|
||||
filter,
|
||||
languages,
|
||||
fallbackLanguage = true,
|
||||
relations = [],
|
||||
}: {
|
||||
after?: string;
|
||||
limit: number;
|
||||
query?: string;
|
||||
sort?: StaticDecode<typeof studioSort>;
|
||||
filter?: SQL;
|
||||
languages: string[];
|
||||
fallbackLanguage?: boolean;
|
||||
preferOriginal?: boolean;
|
||||
relations?: (keyof typeof studioRelations)[];
|
||||
}) {
|
||||
const transQ = db
|
||||
.selectDistinctOn([studioTranslations.pk])
|
||||
.from(studioTranslations)
|
||||
.where(
|
||||
!fallbackLanguage
|
||||
? eq(studioTranslations.language, sql`any(${sqlarr(languages)})`)
|
||||
: undefined,
|
||||
)
|
||||
.orderBy(
|
||||
studioTranslations.pk,
|
||||
sql`array_position(${sqlarr(languages)}, ${studioTranslations.language})`,
|
||||
)
|
||||
.as("t");
|
||||
|
||||
return await db
|
||||
.select({
|
||||
...getColumns(studios),
|
||||
...getColumns(transQ),
|
||||
...buildRelations(relations, studioRelations),
|
||||
})
|
||||
.from(studios)
|
||||
[fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")](
|
||||
transQ,
|
||||
eq(studios.pk, transQ.pk),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
filter,
|
||||
query ? sql`${transQ.name} %> ${query}::text` : undefined,
|
||||
keysetPaginate({ table: studios, after, sort }),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
...(query
|
||||
? [sql`word_similarity(${query}::text, ${transQ.name})`]
|
||||
: sortToSql(sort, studios)),
|
||||
studios.pk,
|
||||
)
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||
.model({
|
||||
studio: Studio,
|
||||
@ -45,21 +128,12 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||
set,
|
||||
}) => {
|
||||
const langs = processLanguages(languages);
|
||||
const ret = await db.query.studios.findFirst({
|
||||
where: isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id),
|
||||
with: {
|
||||
selectedTranslation: selectTranslationQuery(
|
||||
studioTranslations,
|
||||
langs,
|
||||
),
|
||||
...(relations.includes("translations") && {
|
||||
translations: {
|
||||
columns: {
|
||||
pk: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
const [ret] = await getStudios({
|
||||
limit: 1,
|
||||
filter: isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id),
|
||||
languages: langs,
|
||||
fallbackLanguage: langs.includes("*"),
|
||||
relations,
|
||||
});
|
||||
if (!ret) {
|
||||
return error(404, {
|
||||
@ -67,20 +141,14 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||
message: `No studio with the id or slug: '${id}'`,
|
||||
});
|
||||
}
|
||||
const tr = ret.selectedTranslation[0];
|
||||
set.headers["content-language"] = tr.language;
|
||||
return {
|
||||
...ret,
|
||||
...tr,
|
||||
...(ret.translations && {
|
||||
translations: Object.fromEntries(
|
||||
ret.translations.map(
|
||||
({ language, ...translation }) =>
|
||||
[language, translation] as const,
|
||||
),
|
||||
),
|
||||
}),
|
||||
};
|
||||
if (!ret.language) {
|
||||
return error(422, {
|
||||
status: 422,
|
||||
message: "Accept-Language header could not be satisfied.",
|
||||
});
|
||||
}
|
||||
set.headers["content-language"] = ret.language;
|
||||
return ret;
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
@ -150,35 +218,13 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||
request: { url },
|
||||
}) => {
|
||||
const langs = processLanguages(languages);
|
||||
const transQ = db
|
||||
.selectDistinctOn([studioTranslations.pk])
|
||||
.from(studioTranslations)
|
||||
.orderBy(
|
||||
studioTranslations.pk,
|
||||
sql`array_position(${sqlarr(langs)}, ${studioTranslations.language}`,
|
||||
)
|
||||
.as("t");
|
||||
const { pk, ...transCol } = getColumns(transQ);
|
||||
|
||||
const items = await db
|
||||
.select({
|
||||
...getColumns(studios),
|
||||
...transCol,
|
||||
})
|
||||
.from(studios)
|
||||
.where(
|
||||
and(
|
||||
query ? sql`${transQ.name} %> ${query}::text` : undefined,
|
||||
keysetPaginate({ table: studios, after, sort }),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
...(query
|
||||
? [sql`word_similarity(${query}::text, ${transQ.name})`]
|
||||
: sortToSql(sort, studios)),
|
||||
studios.pk,
|
||||
)
|
||||
.limit(limit);
|
||||
const items = await getStudios({
|
||||
limit,
|
||||
after,
|
||||
query,
|
||||
sort,
|
||||
languages: langs,
|
||||
});
|
||||
return createPage(items, { url, sort, limit });
|
||||
},
|
||||
{
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { Image, Original } from "~/models/utils";
|
||||
import { entries } from "./entries";
|
||||
import { seasons } from "./seasons";
|
||||
import { showStudioJoin } from "./studios";
|
||||
@ -54,6 +56,13 @@ export const genres = schema.enum("genres", [
|
||||
"talk",
|
||||
]);
|
||||
|
||||
type OriginalWithImages = Original & {
|
||||
poster: Image | null;
|
||||
thumbnail: Image | null;
|
||||
banner: Image | null;
|
||||
logo: Image | null;
|
||||
};
|
||||
|
||||
export const shows = schema.table(
|
||||
"shows",
|
||||
{
|
||||
@ -67,7 +76,7 @@ export const shows = schema.table(
|
||||
status: showStatus().notNull(),
|
||||
startAir: date(),
|
||||
endAir: date(),
|
||||
originalLanguage: language(),
|
||||
original: jsonb().$type<OriginalWithImages>().notNull(),
|
||||
|
||||
collectionPk: integer().references((): AnyPgColumn => shows.pk, {
|
||||
onDelete: "set null",
|
||||
@ -120,16 +129,8 @@ export const showTranslations = schema.table(
|
||||
],
|
||||
);
|
||||
|
||||
export const showsRelations = relations(shows, ({ many, one }) => ({
|
||||
selectedTranslation: many(showTranslations, {
|
||||
relationName: "selected_translation",
|
||||
}),
|
||||
export const showsRelations = relations(shows, ({ many }) => ({
|
||||
translations: many(showTranslations, { relationName: "show_translations" }),
|
||||
originalTranslation: one(showTranslations, {
|
||||
relationName: "original_translation",
|
||||
fields: [shows.pk, shows.originalLanguage],
|
||||
references: [showTranslations.pk, showTranslations.language],
|
||||
}),
|
||||
entries: many(entries, { relationName: "show_entries" }),
|
||||
seasons: many(seasons, { relationName: "show_seasons" }),
|
||||
studios: many(showStudioJoin, { relationName: "ssj_show" }),
|
||||
@ -140,14 +141,4 @@ export const showsTrRelations = relations(showTranslations, ({ one }) => ({
|
||||
fields: [showTranslations.pk],
|
||||
references: [shows.pk],
|
||||
}),
|
||||
selectedTranslation: one(shows, {
|
||||
relationName: "selected_translation",
|
||||
fields: [showTranslations.pk],
|
||||
references: [shows.pk],
|
||||
}),
|
||||
originalTranslation: one(shows, {
|
||||
relationName: "original_translation",
|
||||
fields: [showTranslations.pk, showTranslations.language],
|
||||
references: [shows.pk, shows.originalLanguage],
|
||||
}),
|
||||
}));
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { jsonb, pgSchema, varchar } from "drizzle-orm/pg-core";
|
||||
import type { Image } from "~/models/utils";
|
||||
|
||||
export const schema = pgSchema("kyoo");
|
||||
|
||||
export const language = () => varchar({ length: 255 });
|
||||
|
||||
export const image = () =>
|
||||
jsonb().$type<{ id: string; source: string; blurhash: string }>();
|
||||
export const image = () => jsonb().$type<Image>();
|
||||
|
||||
export const externalid = () =>
|
||||
jsonb()
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { Guess } from "~/models/video";
|
||||
import { entries } from "./entries";
|
||||
import { schema } from "./utils";
|
||||
|
||||
@ -21,7 +22,7 @@ export const videos = schema.table(
|
||||
rendering: text().notNull(),
|
||||
part: integer(),
|
||||
version: integer().notNull().default(1),
|
||||
guess: jsonb().notNull().default({}),
|
||||
guess: jsonb().$type<Guess>().notNull(),
|
||||
|
||||
createdAt: timestamp({ withTimezone: true, mode: "string" })
|
||||
.notNull()
|
||||
|
@ -1,6 +1,8 @@
|
||||
import {
|
||||
type ColumnsSelection,
|
||||
InferColumnsDataTypes,
|
||||
type SQL,
|
||||
type SQLWrapper,
|
||||
type Subquery,
|
||||
Table,
|
||||
View,
|
||||
@ -92,3 +94,27 @@ export function values(items: Record<string, unknown>[]) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const coalesce = <T>(val: SQL<T>, def: SQLWrapper) => {
|
||||
return sql<T>`coalesce(${val}, ${def})`;
|
||||
};
|
||||
|
||||
export const jsonbObjectAgg = <T>(key: SQLWrapper, value: SQL<T>) => {
|
||||
return sql<
|
||||
Record<string, T>
|
||||
>`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`;
|
||||
};
|
||||
|
||||
export const jsonbAgg = <T>(val: SQL<T>) => {
|
||||
return sql<T[]>`jsonb_agg(${val})`;
|
||||
};
|
||||
|
||||
export const jsonbBuildObject = <T>(select: Record<string, SQLWrapper>) => {
|
||||
const query = sql.join(
|
||||
Object.entries(select).flatMap(([k, v]) => {
|
||||
return [sql.raw(`'${k}'`), v];
|
||||
}),
|
||||
sql.raw(", "),
|
||||
);
|
||||
return sql<T>`jsonb_build_object(${query})`;
|
||||
};
|
||||
|
@ -7,13 +7,13 @@ import {
|
||||
Genre,
|
||||
Image,
|
||||
Language,
|
||||
Original,
|
||||
Resource,
|
||||
SeedImage,
|
||||
TranslationRecord,
|
||||
} from "./utils";
|
||||
|
||||
const BaseCollection = t.Object({
|
||||
kind: t.Literal("collection"),
|
||||
genres: t.Array(Genre),
|
||||
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
||||
startAir: t.Nullable(
|
||||
@ -28,14 +28,7 @@ const BaseCollection = t.Object({
|
||||
descrpition: "Date of the last item of the collection",
|
||||
}),
|
||||
),
|
||||
originalLanguage: t.Nullable(
|
||||
Language({
|
||||
description: "The language code this movie was made in.",
|
||||
}),
|
||||
),
|
||||
|
||||
nextRefresh: t.String({ format: "date-time" }),
|
||||
|
||||
externalId: ExternalId(),
|
||||
});
|
||||
|
||||
@ -57,6 +50,9 @@ export const Collection = t.Intersect([
|
||||
CollectionTranslation,
|
||||
BaseCollection,
|
||||
DbMetadata,
|
||||
t.Object({
|
||||
original: Original,
|
||||
}),
|
||||
]);
|
||||
export type Collection = Prettify<typeof Collection.static>;
|
||||
|
||||
@ -72,6 +68,9 @@ export const SeedCollection = t.Intersect([
|
||||
t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]),
|
||||
t.Object({
|
||||
slug: t.String({ format: "slug" }),
|
||||
originalLanguage: Language({
|
||||
description: "The language code this collection's items were made in.",
|
||||
}),
|
||||
translations: TranslationRecord(
|
||||
t.Intersect([
|
||||
t.Omit(CollectionTranslation, [
|
||||
@ -85,6 +84,7 @@ export const SeedCollection = t.Intersect([
|
||||
thumbnail: t.Nullable(SeedImage),
|
||||
banner: t.Nullable(SeedImage),
|
||||
logo: t.Nullable(SeedImage),
|
||||
latinName: t.Optional(Original.properties.latinName),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
|
@ -8,6 +8,12 @@ export const bubbleVideo: Video = {
|
||||
rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd",
|
||||
part: null,
|
||||
version: 1,
|
||||
guess: {
|
||||
kind: "movie",
|
||||
title: "bubble",
|
||||
year: [2022],
|
||||
from: "guessit",
|
||||
},
|
||||
createdAt: "2024-11-23T15:01:24.968Z",
|
||||
updatedAt: "2024-11-23T15:01:24.968Z",
|
||||
};
|
||||
@ -32,6 +38,7 @@ export const bubble: SeedMovie = {
|
||||
},
|
||||
ja: {
|
||||
name: "バブル:2022",
|
||||
latinName: "Buburu",
|
||||
tagline: null,
|
||||
description: null,
|
||||
aliases: ["Baburu", "Bubble"],
|
||||
|
@ -8,6 +8,12 @@ export const dune1984Video: Video = {
|
||||
rendering: "ea3a0f8f2f2c5b61a07f61e4e8d9f8e01b2b92bcbb6f5ed1151e1f61619c2c0f",
|
||||
part: null,
|
||||
version: 1,
|
||||
guess: {
|
||||
kind: "movie",
|
||||
title: "dune",
|
||||
year: [1984],
|
||||
from: "guessit",
|
||||
},
|
||||
createdAt: "2024-12-02T11:45:12.968Z",
|
||||
updatedAt: "2024-12-02T11:45:12.968Z",
|
||||
};
|
||||
|
@ -8,6 +8,12 @@ export const duneVideo: Video = {
|
||||
rendering: "f1953a4fb58247efb6c15b76468b6a9d13b4155b02094863b1a4f0c3fbb6db58",
|
||||
part: null,
|
||||
version: 1,
|
||||
guess: {
|
||||
kind: "movie",
|
||||
title: "dune",
|
||||
year: [2021],
|
||||
from: "guessit",
|
||||
},
|
||||
createdAt: "2024-12-02T10:10:24.968Z",
|
||||
updatedAt: "2024-12-02T10:10:24.968Z",
|
||||
};
|
||||
|
@ -60,6 +60,40 @@ export const madeInAbyss = {
|
||||
banner: null,
|
||||
trailerUrl: "https://www.youtube.com/watch?v=ePOyy6Wlk4s",
|
||||
},
|
||||
ja: {
|
||||
name: "メイドインアビス",
|
||||
tagline: "さぁ 大穴(アビス)へ――",
|
||||
aliases: ["烈日の黄金郷"],
|
||||
description:
|
||||
"隅々まで探索されつくした世界に、唯一残された秘境の大穴『アビス』。どこまで続くとも知れない深く巨大なその縦穴には、奇妙奇怪な生物たちが生息し、今の人類では作りえない貴重な遺物が眠っている。「アビス」の不可思議に満ちた姿は人々を魅了し、冒険へと駆り立てた。そうして幾度も大穴に挑戦する冒険者たちは、次第に『探窟家』と呼ばれるようになっていった。 アビスの縁に築かれた街『オース』に暮らす孤児のリコは、いつか母のような偉大な探窟家になり、アビスの謎を解き明かすことを夢見ていた。そんなある日、リコはアビスを探窟中に、少年の姿をしたロボットを拾い…?",
|
||||
tags: [
|
||||
"android",
|
||||
"amnesia",
|
||||
"post-apocalyptic future",
|
||||
"exploration",
|
||||
"friendship",
|
||||
"mecha",
|
||||
"survival",
|
||||
"curse",
|
||||
"tragedy",
|
||||
"orphan",
|
||||
"based on manga",
|
||||
"robot",
|
||||
"dark fantasy",
|
||||
"seinen",
|
||||
"anime",
|
||||
"drastic change of life",
|
||||
"fantasy",
|
||||
"adventure",
|
||||
],
|
||||
poster:
|
||||
"https://image.tmdb.org/t/p/original/4Bh9qzB1Kau4RDaVQXVFdoJ0HcE.jpg",
|
||||
thumbnail:
|
||||
"https://image.tmdb.org/t/p/original/Df9XrvZFIeQfLKfu8evRmzvRsd.jpg",
|
||||
logo: "https://image.tmdb.org/t/p/original/7hY3Q4GhkiYPBfn4UoVg0AO4Zgk.png",
|
||||
banner: null,
|
||||
trailerUrl: "https://www.youtube.com/watch?v=ePOyy6Wlk4s",
|
||||
},
|
||||
},
|
||||
genres: [
|
||||
"animation",
|
||||
|
@ -13,29 +13,21 @@ import {
|
||||
SeedImage,
|
||||
TranslationRecord,
|
||||
} from "./utils";
|
||||
import { Original } from "./utils/original";
|
||||
import { Video } from "./video";
|
||||
|
||||
export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]);
|
||||
export type MovieStatus = typeof MovieStatus.static;
|
||||
|
||||
const BaseMovie = t.Object({
|
||||
kind: t.Literal("movie"),
|
||||
genres: t.Array(Genre),
|
||||
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
||||
status: MovieStatus,
|
||||
runtime: t.Nullable(
|
||||
t.Number({ minimum: 0, description: "Runtime of the movie in minutes." }),
|
||||
),
|
||||
|
||||
airDate: t.Nullable(t.String({ format: "date" })),
|
||||
originalLanguage: t.Nullable(
|
||||
Language({
|
||||
description: "The language code this movie was made in.",
|
||||
}),
|
||||
),
|
||||
|
||||
nextRefresh: t.String({ format: "date-time" }),
|
||||
|
||||
externalId: ExternalId(),
|
||||
});
|
||||
|
||||
@ -60,6 +52,7 @@ export const Movie = t.Intersect([
|
||||
BaseMovie,
|
||||
DbMetadata,
|
||||
t.Object({
|
||||
original: Original,
|
||||
isAvailable: t.Boolean(),
|
||||
}),
|
||||
]);
|
||||
@ -79,6 +72,9 @@ export const SeedMovie = t.Intersect([
|
||||
t.Omit(BaseMovie, ["kind", "nextRefresh"]),
|
||||
t.Object({
|
||||
slug: t.String({ format: "slug", examples: ["bubble"] }),
|
||||
originalLanguage: Language({
|
||||
description: "The language code this movie was made in.",
|
||||
}),
|
||||
translations: TranslationRecord(
|
||||
t.Intersect([
|
||||
t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]),
|
||||
@ -87,6 +83,7 @@ export const SeedMovie = t.Intersect([
|
||||
thumbnail: t.Nullable(SeedImage),
|
||||
banner: t.Nullable(SeedImage),
|
||||
logo: t.Nullable(SeedImage),
|
||||
latinName: t.Optional(Original.properties.latinName),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
SeedImage,
|
||||
TranslationRecord,
|
||||
} from "./utils";
|
||||
import { Original } from "./utils/original";
|
||||
|
||||
export const SerieStatus = t.UnionEnum([
|
||||
"unknown",
|
||||
@ -25,7 +26,6 @@ export const SerieStatus = t.UnionEnum([
|
||||
export type SerieStatus = typeof SerieStatus.static;
|
||||
|
||||
const BaseSerie = t.Object({
|
||||
kind: t.Literal("serie"),
|
||||
genres: t.Array(Genre),
|
||||
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
||||
status: SerieStatus,
|
||||
@ -35,23 +35,9 @@ const BaseSerie = t.Object({
|
||||
description: "Average runtime of all episodes (in minutes.)",
|
||||
}),
|
||||
),
|
||||
|
||||
startAir: t.Nullable(t.String({ format: "date" })),
|
||||
endAir: t.Nullable(t.String({ format: "date" })),
|
||||
originalLanguage: t.Nullable(
|
||||
Language({
|
||||
description: "The language code this serie was made in.",
|
||||
}),
|
||||
),
|
||||
|
||||
nextRefresh: t.String({ format: "date-time" }),
|
||||
entriesCount: t.Integer({
|
||||
description: "The number of episodes in this serie",
|
||||
}),
|
||||
availableCount: t.Integer({
|
||||
description: "The number of episodes that can be played right away",
|
||||
}),
|
||||
|
||||
externalId: ExternalId(),
|
||||
});
|
||||
|
||||
@ -75,6 +61,15 @@ export const Serie = t.Intersect([
|
||||
SerieTranslation,
|
||||
BaseSerie,
|
||||
DbMetadata,
|
||||
t.Object({
|
||||
original: Original,
|
||||
entriesCount: t.Integer({
|
||||
description: "The number of episodes in this serie",
|
||||
}),
|
||||
availableCount: t.Integer({
|
||||
description: "The number of episodes that can be played right away",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
export type Serie = Prettify<typeof Serie.static>;
|
||||
|
||||
@ -88,9 +83,12 @@ export const FullSerie = t.Intersect([
|
||||
export type FullMovie = Prettify<typeof FullSerie.static>;
|
||||
|
||||
export const SeedSerie = t.Intersect([
|
||||
t.Omit(BaseSerie, ["kind", "nextRefresh", "entriesCount", "availableCount"]),
|
||||
t.Omit(BaseSerie, ["kind", "nextRefresh"]),
|
||||
t.Object({
|
||||
slug: t.String({ format: "slug" }),
|
||||
originalLanguage: Language({
|
||||
description: "The language code this serie was made in.",
|
||||
}),
|
||||
translations: TranslationRecord(
|
||||
t.Intersect([
|
||||
t.Omit(SerieTranslation, ["poster", "thumbnail", "banner", "logo"]),
|
||||
@ -99,6 +97,7 @@ export const SeedSerie = t.Intersect([
|
||||
thumbnail: t.Nullable(SeedImage),
|
||||
banner: t.Nullable(SeedImage),
|
||||
logo: t.Nullable(SeedImage),
|
||||
latinName: t.Optional(Original.properties.latinName),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
|
@ -3,4 +3,8 @@ import { Collection } from "./collections";
|
||||
import { Movie } from "./movie";
|
||||
import { Serie } from "./serie";
|
||||
|
||||
export const Show = t.Union([Movie, Serie, Collection]);
|
||||
export const Show = t.Union([
|
||||
t.Intersect([Movie, t.Object({ kind: t.Literal("movie") })]),
|
||||
t.Intersect([Serie, t.Object({ kind: t.Literal("serie") })]),
|
||||
t.Intersect([Collection, t.Object({ kind: t.Literal("collection") })]),
|
||||
]);
|
||||
|
@ -12,6 +12,7 @@ export const StudioTranslation = t.Object({
|
||||
name: t.String(),
|
||||
logo: t.Nullable(Image),
|
||||
});
|
||||
export type StudioTranslation = typeof StudioTranslation.static;
|
||||
|
||||
export const Studio = t.Intersect([
|
||||
Resource(),
|
||||
|
@ -8,3 +8,5 @@ export * from "./page";
|
||||
export * from "./sort";
|
||||
export * from "./keyset-paginate";
|
||||
export * from "./db-metadata";
|
||||
export * from "./original";
|
||||
export * from "./relations";
|
||||
|
@ -26,9 +26,9 @@ export const keysetPaginate = <
|
||||
}: {
|
||||
table: Table<"pk" | Sort<T, Remap>["sort"][number]["key"]>;
|
||||
after: string | undefined;
|
||||
sort: Sort<T, Remap>;
|
||||
sort: Sort<T, Remap> | undefined;
|
||||
}) => {
|
||||
if (!after) return undefined;
|
||||
if (!after || !sort) return undefined;
|
||||
const cursor: After = JSON.parse(
|
||||
Buffer.from(after, "base64").toString("utf-8"),
|
||||
);
|
||||
|
@ -23,7 +23,6 @@ export const Language = (props?: NonNullable<Parameters<typeof t.String>[0]>) =>
|
||||
This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages).
|
||||
BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it.
|
||||
`,
|
||||
error: "Expected a valid (and NORMALIZED) bcp-47 language code.",
|
||||
examples: ["en-US"],
|
||||
...props,
|
||||
}),
|
||||
@ -108,19 +107,3 @@ export const AcceptLanguage = ({
|
||||
`
|
||||
: ""),
|
||||
});
|
||||
|
||||
export const selectTranslationQuery = (
|
||||
translationTable: Table & { language: Column },
|
||||
languages: string[],
|
||||
) => ({
|
||||
columns: {
|
||||
pk: false,
|
||||
} as const,
|
||||
where: !languages.includes("*")
|
||||
? eq(translationTable.language, sql`any(${sqlarr(languages)})`)
|
||||
: undefined,
|
||||
orderBy: [
|
||||
sql`array_position(${sqlarr(languages)}, ${translationTable.language})`,
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
25
api/src/models/utils/original.ts
Normal file
25
api/src/models/utils/original.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { t } from "elysia";
|
||||
import { comment } from "~/utils";
|
||||
import { Language } from "./language";
|
||||
|
||||
export const Original = t.Object({
|
||||
language: Language({
|
||||
description: "The language code this was made in.",
|
||||
examples: ["ja"],
|
||||
}),
|
||||
name: t.String({
|
||||
description: "The name in the original language",
|
||||
examples: ["進撃の巨人"],
|
||||
}),
|
||||
latinName: t.Nullable(
|
||||
t.String({
|
||||
description: comment`
|
||||
The original name but using latin scripts.
|
||||
This is only set if the original language is written with another
|
||||
alphabet (like japanase, korean, chineses...)
|
||||
`,
|
||||
examples: ["Shingeki no Kyojin"],
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type Original = typeof Original.static;
|
26
api/src/models/utils/relations.ts
Normal file
26
api/src/models/utils/relations.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { type SQL, type Subquery, sql } from "drizzle-orm";
|
||||
import type { SelectResultField } from "drizzle-orm/query-builders/select.types";
|
||||
|
||||
export const buildRelations = <
|
||||
R extends string,
|
||||
P extends object,
|
||||
Rel extends Record<R, (languages: P) => Subquery>,
|
||||
>(
|
||||
enabled: R[],
|
||||
relations: Rel,
|
||||
params?: P,
|
||||
) => {
|
||||
// we wrap that in a sql`` instead of using the builder because of this issue
|
||||
// https://github.com/drizzle-team/drizzle-orm/pull/1674
|
||||
return Object.fromEntries(
|
||||
enabled.map((x) => [x, sql`${relations[x](params!)}`]),
|
||||
) as {
|
||||
[P in R]?: SQL<
|
||||
ReturnType<Rel[P]>["_"]["selectedFields"] extends {
|
||||
[key: string]: infer TValue;
|
||||
}
|
||||
? SelectResultField<TValue>
|
||||
: never
|
||||
>;
|
||||
};
|
||||
};
|
@ -78,9 +78,10 @@ export const sortToSql = <
|
||||
T extends string[],
|
||||
Remap extends Partial<Record<T[number], string>>,
|
||||
>(
|
||||
sort: Sort<T, Remap>,
|
||||
sort: Sort<T, Remap> | undefined,
|
||||
table: Table<Sort<T, Remap>["sort"][number]["key"] | "pk">,
|
||||
) => {
|
||||
if (!sort) return [];
|
||||
if (sort.random) {
|
||||
return [sql`md5(${sort.random.seed} || ${table.pk})`];
|
||||
}
|
||||
|
@ -3,6 +3,42 @@ import { type Prettify, comment } from "~/utils";
|
||||
import { bubbleVideo, registerExamples } from "./examples";
|
||||
import { DbMetadata, Resource } from "./utils";
|
||||
|
||||
export const Guess = t.Recursive((Self) =>
|
||||
t.Object(
|
||||
{
|
||||
title: t.String(),
|
||||
year: t.Optional(t.Array(t.Integer(), { default: [] })),
|
||||
season: t.Optional(t.Array(t.Integer(), { default: [] })),
|
||||
episode: t.Optional(t.Array(t.Integer(), { default: [] })),
|
||||
// TODO: maybe replace "extra" with the `extraKind` value (aka behind-the-scene, trailer, etc)
|
||||
kind: t.Optional(t.UnionEnum(["episode", "movie", "extra"])),
|
||||
|
||||
from: t.String({
|
||||
description: "Name of the tool that made the guess",
|
||||
}),
|
||||
history: t.Optional(
|
||||
t.Array(t.Omit(Self, ["history"]), {
|
||||
default: [],
|
||||
description: comment`
|
||||
When another tool refines the guess or a user manually edit it, the history of the guesses
|
||||
are kept in this \`history\` value.
|
||||
`,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
additionalProperties: true,
|
||||
description: comment`
|
||||
Metadata guessed from the filename. Kyoo can use those informations to bypass
|
||||
the scanner/metadata fetching and just register videos to movies/entries that already
|
||||
exists. If Kyoo can't find a matching movie/entry, this information will be sent to
|
||||
the scanner.
|
||||
`,
|
||||
},
|
||||
),
|
||||
);
|
||||
export type Guess = typeof Guess.static;
|
||||
|
||||
export const SeedVideo = t.Object({
|
||||
path: t.String(),
|
||||
rendering: t.String({
|
||||
@ -29,42 +65,7 @@ export const SeedVideo = t.Object({
|
||||
"Kyoo will prefer playing back the highest `version` number if there are multiples rendering.",
|
||||
}),
|
||||
|
||||
guess: t.Optional(
|
||||
t.Recursive((Self) =>
|
||||
t.Object(
|
||||
{
|
||||
title: t.String(),
|
||||
year: t.Optional(t.Array(t.Integer(), { default: [] })),
|
||||
season: t.Optional(t.Array(t.Integer(), { default: [] })),
|
||||
episode: t.Optional(t.Array(t.Integer(), { default: [] })),
|
||||
// TODO: maybe replace "extra" with the `extraKind` value (aka behind-the-scene, trailer, etc)
|
||||
kind: t.Optional(t.UnionEnum(["episode", "movie", "extra"])),
|
||||
|
||||
from: t.String({
|
||||
description: "Name of the tool that made the guess",
|
||||
}),
|
||||
history: t.Optional(
|
||||
t.Array(t.Omit(Self, ["history"]), {
|
||||
default: [],
|
||||
description: comment`
|
||||
When another tool refines the guess or a user manually edit it, the history of the guesses
|
||||
are kept in this \`history\` value.
|
||||
`,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
additionalProperties: true,
|
||||
description: comment`
|
||||
Metadata guessed from the filename. Kyoo can use those informations to bypass
|
||||
the scanner/metadata fetching and just register videos to movies/entries that already
|
||||
exists. If Kyoo can't find a matching movie/entry, this information will be sent to
|
||||
the scanner.
|
||||
`,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
guess: Guess,
|
||||
});
|
||||
export type SeedVideo = typeof SeedVideo.static;
|
||||
|
||||
@ -72,7 +73,7 @@ export const Video = t.Intersect([Resource(), SeedVideo, DbMetadata]);
|
||||
export type Video = Prettify<typeof Video.static>;
|
||||
|
||||
// type used in entry responses
|
||||
export const EmbeddedVideo = t.Omit(Video, ["createdAt", "updatedAt"]);
|
||||
export const EmbeddedVideo = t.Omit(Video, ["guess", "createdAt", "updatedAt"]);
|
||||
export type EmbeddedVideo = Prettify<typeof EmbeddedVideo.static>;
|
||||
|
||||
registerExamples(Video, bubbleVideo);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { db, migrate } from "~/db";
|
||||
import { shows, videos } from "~/db/schema";
|
||||
import { madeInAbyss, madeInAbyssVideo } from "~/models/examples";
|
||||
import { createSerie, createVideo } from "./helpers";
|
||||
import { createSerie, createVideo, getSerie } from "./helpers";
|
||||
|
||||
// test file used to run manually using `bun tests/manual.ts`
|
||||
|
||||
@ -13,3 +13,5 @@ const [_, vid] = await createVideo(madeInAbyssVideo);
|
||||
console.log(vid);
|
||||
const [__, ser] = await createSerie(madeInAbyss);
|
||||
console.log(ser);
|
||||
const [___, got] = await getSerie(madeInAbyss.slug, { with: ["translations"] });
|
||||
console.log(got);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { expectStatus } from "tests/utils";
|
||||
import { seedMovie } from "~/controllers/seed/movies";
|
||||
import { db } from "~/db";
|
||||
import { shows } from "~/db/schema";
|
||||
import { bubble } from "~/models/examples";
|
||||
@ -10,7 +9,10 @@ import { app, createMovie, getMovies } from "../helpers";
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.delete(shows);
|
||||
for (const movie of [bubble, dune1984, dune]) await seedMovie(movie);
|
||||
for (const movie of [bubble, dune1984, dune]) {
|
||||
const [ret, _] = await createMovie(movie);
|
||||
expect(ret.status).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
describe("with a null value", () => {
|
||||
@ -39,7 +41,7 @@ describe("with a null value", () => {
|
||||
rating: null,
|
||||
runtime: null,
|
||||
airDate: null,
|
||||
originalLanguage: null,
|
||||
originalLanguage: "en",
|
||||
externalId: {},
|
||||
studios: [],
|
||||
});
|
||||
|
@ -1,18 +1,19 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { expectStatus } from "tests/utils";
|
||||
import { seedMovie } from "~/controllers/seed/movies";
|
||||
import { db } from "~/db";
|
||||
import { shows } from "~/db/schema";
|
||||
import { bubble } from "~/models/examples";
|
||||
import { dune1984 } from "~/models/examples/dune-1984";
|
||||
import { dune } from "~/models/examples/dune-2021";
|
||||
import type { Movie } from "~/models/movie";
|
||||
import { isUuid } from "~/models/utils";
|
||||
import { app, getMovies } from "../helpers";
|
||||
import { app, createMovie, getMovies } from "../helpers";
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.delete(shows);
|
||||
for (const movie of [bubble, dune1984, dune]) await seedMovie(movie);
|
||||
for (const movie of [bubble, dune1984, dune]) {
|
||||
const [ret, _] = await createMovie(movie);
|
||||
expect(ret.status).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Get all movies", () => {
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { expectStatus } from "tests/utils";
|
||||
import { seedMovie } from "~/controllers/seed/movies";
|
||||
import { db } from "~/db";
|
||||
import { shows, videos } from "~/db/schema";
|
||||
import { bubble, bubbleVideo } from "~/models/examples";
|
||||
import { getMovie } from "../helpers";
|
||||
import { createMovie, getMovie } from "../helpers";
|
||||
|
||||
let bubbleId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.delete(shows);
|
||||
await db.insert(videos).values(bubbleVideo);
|
||||
const ret = await seedMovie(bubble);
|
||||
if (!("status" in ret)) bubbleId = ret.id;
|
||||
const [ret, body] = await createMovie(bubble);
|
||||
expect(ret.status).toBe(201);
|
||||
bubbleId = body.id;
|
||||
});
|
||||
|
||||
describe("Get movie", () => {
|
||||
@ -124,7 +124,7 @@ describe("Get movie", () => {
|
||||
expect(body.isAvailable).toBe(true);
|
||||
});
|
||||
it("With isAvailable=false", async () => {
|
||||
await seedMovie({
|
||||
await createMovie({
|
||||
...bubble,
|
||||
slug: "no-video",
|
||||
videos: [],
|
||||
|
@ -168,7 +168,7 @@ describe("Movie seeding", () => {
|
||||
const [resp, body] = await createMovie({
|
||||
...bubble,
|
||||
slug: "casing-test",
|
||||
originalLanguage: "jp-jp",
|
||||
originalLanguage: "en-us",
|
||||
translations: {
|
||||
"en-us": {
|
||||
name: "foo",
|
||||
@ -191,7 +191,7 @@ describe("Movie seeding", () => {
|
||||
where: eq(shows.id, body.id),
|
||||
with: { translations: true },
|
||||
});
|
||||
expect(ret!.originalLanguage).toBe("jp-JP");
|
||||
expect(ret!.original.language).toBe("en-US");
|
||||
expect(ret!.translations).toBeArrayOfSize(2);
|
||||
expect(ret!.translations).toEqual(
|
||||
expect.arrayContaining([
|
||||
@ -229,7 +229,10 @@ describe("Movie seeding", () => {
|
||||
const [resp, body] = await createMovie({
|
||||
...bubble,
|
||||
slug: "bubble-translation-test",
|
||||
translations: { "en-us": bubble.translations.en },
|
||||
translations: {
|
||||
"en-us": bubble.translations.en,
|
||||
ja: bubble.translations.ja,
|
||||
},
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
|
||||
@ -262,6 +265,7 @@ describe("Movie seeding", () => {
|
||||
"en-us": bubble.translations.en,
|
||||
"en-au": { ...bubble.translations.en, name: "australian thing" },
|
||||
en: { ...bubble.translations.en, name: "Generic" },
|
||||
ja: bubble.translations.ja,
|
||||
},
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
@ -304,6 +308,7 @@ describe("Movie seeding", () => {
|
||||
part: null,
|
||||
version: 1,
|
||||
rendering: "oeunhtoeuth",
|
||||
guess: { title: "bubble", from: "test" },
|
||||
});
|
||||
expectStatus(vresp, video).toBe(201);
|
||||
|
||||
@ -329,6 +334,7 @@ describe("Movie seeding", () => {
|
||||
part: null,
|
||||
version: 2,
|
||||
rendering: "oeunhtoeuth",
|
||||
guess: { title: "bubble", from: "test" },
|
||||
});
|
||||
expectStatus(vresp, video).toBe(201);
|
||||
|
||||
@ -353,6 +359,7 @@ describe("Movie seeding", () => {
|
||||
part: 1,
|
||||
version: 2,
|
||||
rendering: "oaoeueunhtoeuth",
|
||||
guess: { title: "bubble", from: "test" },
|
||||
});
|
||||
expectStatus(vresp, video).toBe(201);
|
||||
|
||||
@ -378,12 +385,14 @@ describe("Movie seeding", () => {
|
||||
part: null,
|
||||
version: 1,
|
||||
rendering: "oeunhtoeuth",
|
||||
guess: { title: "bubble", from: "test" },
|
||||
},
|
||||
{
|
||||
path: "/video/bubble4.mkv",
|
||||
part: null,
|
||||
version: 1,
|
||||
rendering: "aoeuaoeu",
|
||||
guess: { title: "bubble", from: "test" },
|
||||
},
|
||||
]);
|
||||
expectStatus(vresp, video).toBe(201);
|
||||
|
@ -12,7 +12,7 @@ beforeAll(async () => {
|
||||
await createSerie(madeInAbyss);
|
||||
});
|
||||
|
||||
describe("aet series", () => {
|
||||
describe("Get series", () => {
|
||||
it("Invalid slug", async () => {
|
||||
const [resp, body] = await getSerie("sotneuhn", { langs: "en" });
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user