Add original name & latinName in series/movie (#833)

This commit is contained in:
Zoe Roux 2025-03-09 18:17:36 +01:00 committed by GitHub
commit e0ad458d73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1848 additions and 339 deletions

View 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";

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,13 @@
"when": 1741360992371,
"tag": "0012_available_count",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1741444868735,
"tag": "0013_original",
"breakpoints": true
}
]
}

View File

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

View File

@ -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({

View File

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

View File

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

View File

@ -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: {

View File

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

View File

@ -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: {

View File

@ -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: {

View File

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

View File

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

View File

@ -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()

View File

@ -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()

View File

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

View File

@ -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),
}),
]),
),

View File

@ -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"],

View File

@ -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",
};

View File

@ -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",
};

View File

@ -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",

View File

@ -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),
}),
]),
),

View File

@ -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),
}),
]),
),

View File

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

View File

@ -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(),

View File

@ -8,3 +8,5 @@ export * from "./page";
export * from "./sort";
export * from "./keyset-paginate";
export * from "./db-metadata";
export * from "./original";
export * from "./relations";

View File

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

View File

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

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

View 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
>;
};
};

View File

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

View File

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

View File

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

View File

@ -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: [],
});

View File

@ -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", () => {

View File

@ -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: [],

View File

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

View File

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