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,
|
"when": 1741360992371,
|
||||||
"tag": "0012_available_count",
|
"tag": "0012_available_count",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1741444868735,
|
||||||
|
"tag": "0013_original",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,13 @@ import {
|
|||||||
shows,
|
shows,
|
||||||
videos,
|
videos,
|
||||||
} from "~/db/schema";
|
} from "~/db/schema";
|
||||||
import { getColumns, sqlarr } from "~/db/utils";
|
import {
|
||||||
|
coalesce,
|
||||||
|
getColumns,
|
||||||
|
jsonbAgg,
|
||||||
|
jsonbBuildObject,
|
||||||
|
sqlarr,
|
||||||
|
} from "~/db/utils";
|
||||||
import {
|
import {
|
||||||
Entry,
|
Entry,
|
||||||
type EntryKind,
|
type EntryKind,
|
||||||
@ -35,6 +41,7 @@ import {
|
|||||||
sortToSql,
|
sortToSql,
|
||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
import { desc } from "~/models/utils/descriptions";
|
import { desc } from "~/models/utils/descriptions";
|
||||||
|
import type { EmbeddedVideo } from "~/models/video";
|
||||||
|
|
||||||
const entryFilters: FilterDef = {
|
const entryFilters: FilterDef = {
|
||||||
kind: {
|
kind: {
|
||||||
@ -107,17 +114,21 @@ async function getEntries({
|
|||||||
|
|
||||||
const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos);
|
const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos);
|
||||||
const videosQ = db
|
const videosQ = db
|
||||||
.select({ slug: entryVideoJoin.slug, ...videosCol })
|
.select({
|
||||||
|
videos: coalesce(
|
||||||
|
jsonbAgg(
|
||||||
|
jsonbBuildObject<EmbeddedVideo>({
|
||||||
|
slug: entryVideoJoin.slug,
|
||||||
|
...videosCol,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
sql`'[]'::jsonb`,
|
||||||
|
).as("videos"),
|
||||||
|
})
|
||||||
.from(entryVideoJoin)
|
.from(entryVideoJoin)
|
||||||
.where(eq(entryVideoJoin.entryPk, entries.pk))
|
.where(eq(entryVideoJoin.entryPk, entries.pk))
|
||||||
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
|
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
|
||||||
.as("videos");
|
.as("videos");
|
||||||
const videosJ = db
|
|
||||||
.select({
|
|
||||||
videos: sql`coalesce(json_agg("videos"), '[]'::json)`.as("videos"),
|
|
||||||
})
|
|
||||||
.from(videosQ)
|
|
||||||
.as("videos_json");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
kind,
|
kind,
|
||||||
@ -132,7 +143,7 @@ async function getEntries({
|
|||||||
.select({
|
.select({
|
||||||
...entryCol,
|
...entryCol,
|
||||||
...transCol,
|
...transCol,
|
||||||
videos: videosJ.videos,
|
videos: videosQ.videos,
|
||||||
// specials don't have an `episodeNumber` but a `number` field.
|
// specials don't have an `episodeNumber` but a `number` field.
|
||||||
number: episodeNumber,
|
number: episodeNumber,
|
||||||
|
|
||||||
@ -150,7 +161,7 @@ async function getEntries({
|
|||||||
})
|
})
|
||||||
.from(entries)
|
.from(entries)
|
||||||
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
||||||
.leftJoinLateral(videosJ, sql`true`)
|
.leftJoinLateral(videosQ, sql`true`)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
filter,
|
filter,
|
||||||
|
@ -28,6 +28,7 @@ export const insertCollection = async (
|
|||||||
endAir: show.kind === "movie" ? show.airDate : show.endAir,
|
endAir: show.kind === "movie" ? show.airDate : show.endAir,
|
||||||
nextRefresh: show.nextRefresh,
|
nextRefresh: show.nextRefresh,
|
||||||
entriesCount: 0,
|
entriesCount: 0,
|
||||||
|
original: {} as any,
|
||||||
...col,
|
...col,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import type { SeedMovie } from "~/models/movie";
|
import type { SeedMovie } from "~/models/movie";
|
||||||
import { getYear } from "~/utils";
|
import { getYear } from "~/utils";
|
||||||
|
import { processOptImage } from "./images";
|
||||||
import { insertCollection } from "./insert/collection";
|
import { insertCollection } from "./insert/collection";
|
||||||
import { insertEntries } from "./insert/entries";
|
import { insertEntries } from "./insert/entries";
|
||||||
import { insertShow, updateAvailableCount } from "./insert/shows";
|
import { insertShow, updateAvailableCount } from "./insert/shows";
|
||||||
@ -45,8 +46,15 @@ export const seedMovie = async (
|
|||||||
seed.slug = `random-${getYear(seed.airDate)}`;
|
seed.slug = `random-${getYear(seed.airDate)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { translations, videos, collection, studios, ...bMovie } = seed;
|
const { translations, videos, collection, studios, ...movie } = seed;
|
||||||
const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date());
|
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, {
|
const col = await insertCollection(collection, {
|
||||||
kind: "movie",
|
kind: "movie",
|
||||||
@ -57,11 +65,20 @@ export const seedMovie = async (
|
|||||||
const show = await insertShow(
|
const show = await insertShow(
|
||||||
{
|
{
|
||||||
kind: "movie",
|
kind: "movie",
|
||||||
startAir: bMovie.airDate,
|
startAir: movie.airDate,
|
||||||
nextRefresh,
|
nextRefresh,
|
||||||
collectionPk: col?.pk,
|
collectionPk: col?.pk,
|
||||||
entriesCount: 1,
|
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,
|
translations,
|
||||||
);
|
);
|
||||||
@ -70,11 +87,11 @@ export const seedMovie = async (
|
|||||||
// even if never shown to the user, a movie still has an entry.
|
// even if never shown to the user, a movie still has an entry.
|
||||||
const [entry] = await insertEntries(show, [
|
const [entry] = await insertEntries(show, [
|
||||||
{
|
{
|
||||||
...bMovie,
|
...movie,
|
||||||
kind: "movie",
|
kind: "movie",
|
||||||
order: 1,
|
order: 1,
|
||||||
thumbnail: (bMovie.originalLanguage
|
thumbnail: (movie.originalLanguage
|
||||||
? translations[bMovie.originalLanguage]
|
? translations[movie.originalLanguage]
|
||||||
: Object.values(translations)[0]
|
: Object.values(translations)[0]
|
||||||
)?.thumbnail,
|
)?.thumbnail,
|
||||||
translations,
|
translations,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import type { SeedSerie } from "~/models/serie";
|
import type { SeedSerie } from "~/models/serie";
|
||||||
import { getYear } from "~/utils";
|
import { getYear } from "~/utils";
|
||||||
|
import { processOptImage } from "./images";
|
||||||
import { insertCollection } from "./insert/collection";
|
import { insertCollection } from "./insert/collection";
|
||||||
import { insertEntries } from "./insert/entries";
|
import { insertEntries } from "./insert/entries";
|
||||||
import { insertSeasons } from "./insert/seasons";
|
import { insertSeasons } from "./insert/seasons";
|
||||||
@ -82,6 +83,13 @@ export const seedSerie = async (
|
|||||||
...serie
|
...serie
|
||||||
} = seed;
|
} = seed;
|
||||||
const nextRefresh = guessNextRefresh(serie.startAir ?? new Date());
|
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, {
|
const col = await insertCollection(collection, {
|
||||||
kind: "serie",
|
kind: "serie",
|
||||||
@ -95,6 +103,15 @@ export const seedSerie = async (
|
|||||||
nextRefresh,
|
nextRefresh,
|
||||||
collectionPk: col?.pk,
|
collectionPk: col?.pk,
|
||||||
entriesCount: entries.length,
|
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,
|
...serie,
|
||||||
},
|
},
|
||||||
translations,
|
translations,
|
||||||
|
@ -21,7 +21,7 @@ import {
|
|||||||
processLanguages,
|
processLanguages,
|
||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
import { desc } from "~/models/utils/descriptions";
|
import { desc } from "~/models/utils/descriptions";
|
||||||
import { getShow, getShows, showFilters, showSort } from "./logic";
|
import { getShows, showFilters, showSort } from "./logic";
|
||||||
|
|
||||||
export const collections = new Elysia({
|
export const collections = new Elysia({
|
||||||
prefix: "/collections",
|
prefix: "/collections",
|
||||||
@ -41,11 +41,16 @@ export const collections = new Elysia({
|
|||||||
set,
|
set,
|
||||||
}) => {
|
}) => {
|
||||||
const langs = processLanguages(languages);
|
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,
|
languages: langs,
|
||||||
|
fallbackLanguage: langs.includes("*"),
|
||||||
preferOriginal,
|
preferOriginal,
|
||||||
relations,
|
relations,
|
||||||
filters: eq(shows.kind, "collection"),
|
|
||||||
});
|
});
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
return error(404, {
|
return error(404, {
|
||||||
@ -60,7 +65,7 @@ export const collections = new Elysia({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
set.headers["content-language"] = ret.language;
|
set.headers["content-language"] = ret.language;
|
||||||
return ret.show;
|
return ret;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: {
|
detail: {
|
||||||
|
@ -1,18 +1,31 @@
|
|||||||
import type { StaticDecode } from "@sinclair/typebox";
|
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 { db } from "~/db";
|
||||||
import { showTranslations, shows, studioTranslations } from "~/db/schema";
|
import {
|
||||||
import { getColumns, sqlarr } from "~/db/utils";
|
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 type { MovieStatus } from "~/models/movie";
|
||||||
import { SerieStatus } from "~/models/serie";
|
import { SerieStatus, type SerieTranslation } from "~/models/serie";
|
||||||
|
import type { Studio } from "~/models/studio";
|
||||||
import {
|
import {
|
||||||
type FilterDef,
|
type FilterDef,
|
||||||
Genre,
|
Genre,
|
||||||
type Image,
|
type Image,
|
||||||
Sort,
|
Sort,
|
||||||
isUuid,
|
buildRelations,
|
||||||
keysetPaginate,
|
keysetPaginate,
|
||||||
selectTranslationQuery,
|
|
||||||
sortToSql,
|
sortToSql,
|
||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
|
|
||||||
@ -29,7 +42,10 @@ export const showFilters: FilterDef = {
|
|||||||
airDate: { column: shows.startAir, type: "date" },
|
airDate: { column: shows.startAir, type: "date" },
|
||||||
startAir: { column: shows.startAir, type: "date" },
|
startAir: { column: shows.startAir, type: "date" },
|
||||||
endAir: { 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: {
|
tags: {
|
||||||
column: sql.raw(`t.${showTranslations.tags.name}`),
|
column: sql.raw(`t.${showTranslations.tags.name}`),
|
||||||
type: "string",
|
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({
|
export async function getShows({
|
||||||
after,
|
after,
|
||||||
limit,
|
limit,
|
||||||
@ -59,51 +131,58 @@ export async function getShows({
|
|||||||
sort,
|
sort,
|
||||||
filter,
|
filter,
|
||||||
languages,
|
languages,
|
||||||
preferOriginal,
|
fallbackLanguage = true,
|
||||||
|
preferOriginal = false,
|
||||||
|
relations = [],
|
||||||
}: {
|
}: {
|
||||||
after: string | undefined;
|
after?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
query: string | undefined;
|
query?: string;
|
||||||
sort: StaticDecode<typeof showSort>;
|
sort?: StaticDecode<typeof showSort>;
|
||||||
filter: SQL | undefined;
|
filter?: SQL;
|
||||||
languages: string[];
|
languages: string[];
|
||||||
preferOriginal: boolean | undefined;
|
fallbackLanguage?: boolean;
|
||||||
|
preferOriginal?: boolean;
|
||||||
|
relations?: (keyof typeof showRelations)[];
|
||||||
}) {
|
}) {
|
||||||
const transQ = db
|
const transQ = db
|
||||||
.selectDistinctOn([showTranslations.pk])
|
.selectDistinctOn([showTranslations.pk])
|
||||||
.from(showTranslations)
|
.from(showTranslations)
|
||||||
|
.where(
|
||||||
|
!fallbackLanguage
|
||||||
|
? eq(showTranslations.language, sql`any(${sqlarr(languages)})`)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
showTranslations.pk,
|
showTranslations.pk,
|
||||||
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
|
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
|
||||||
)
|
)
|
||||||
.as("t");
|
.as("t");
|
||||||
const { pk, poster, thumbnail, banner, logo, ...transCol } =
|
|
||||||
getColumns(transQ);
|
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select({
|
||||||
...getColumns(shows),
|
...getColumns(shows),
|
||||||
...transCol,
|
...getColumns(transQ),
|
||||||
|
|
||||||
// movie columns (status is only a typescript hint)
|
// movie columns (status is only a typescript hint)
|
||||||
status: sql<MovieStatus>`${shows.status}`,
|
status: sql<MovieStatus>`${shows.status}`,
|
||||||
airDate: shows.startAir,
|
airDate: shows.startAir,
|
||||||
kind: sql<any>`${shows.kind}`,
|
kind: sql<any>`${shows.kind}`,
|
||||||
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
|
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
|
||||||
|
|
||||||
poster: sql<Image>`coalesce(${showTranslations.poster}, ${poster})`,
|
...(preferOriginal && {
|
||||||
thumbnail: sql<Image>`coalesce(${showTranslations.thumbnail}, ${thumbnail})`,
|
poster: sql<Image>`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`,
|
||||||
banner: sql<Image>`coalesce(${showTranslations.banner}, ${banner})`,
|
thumbnail: sql<Image>`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`,
|
||||||
logo: sql<Image>`coalesce(${showTranslations.logo}, ${logo})`,
|
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)
|
.from(shows)
|
||||||
.innerJoin(transQ, eq(shows.pk, transQ.pk))
|
[fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")](
|
||||||
.leftJoin(
|
transQ,
|
||||||
showTranslations,
|
eq(shows.pk, transQ.pk),
|
||||||
and(
|
|
||||||
sql`${preferOriginal ?? false}`,
|
|
||||||
eq(shows.pk, showTranslations.pk),
|
|
||||||
eq(showTranslations.language, shows.originalLanguage),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@ -120,93 +199,3 @@ export async function getShows({
|
|||||||
)
|
)
|
||||||
.limit(limit);
|
.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,
|
Filter,
|
||||||
Page,
|
Page,
|
||||||
createPage,
|
createPage,
|
||||||
|
isUuid,
|
||||||
processLanguages,
|
processLanguages,
|
||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
import { desc } from "~/models/utils/descriptions";
|
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"] })
|
export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
||||||
.model({
|
.model({
|
||||||
@ -30,11 +31,16 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
set,
|
set,
|
||||||
}) => {
|
}) => {
|
||||||
const langs = processLanguages(languages);
|
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,
|
languages: langs,
|
||||||
|
fallbackLanguage: langs.includes("*"),
|
||||||
preferOriginal,
|
preferOriginal,
|
||||||
relations,
|
relations,
|
||||||
filters: eq(shows.kind, "movie"),
|
|
||||||
});
|
});
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
return error(404, {
|
return error(404, {
|
||||||
@ -49,7 +55,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
set.headers["content-language"] = ret.language;
|
set.headers["content-language"] = ret.language;
|
||||||
return ret.show;
|
return ret;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: {
|
detail: {
|
||||||
|
@ -10,10 +10,11 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Page,
|
Page,
|
||||||
createPage,
|
createPage,
|
||||||
|
isUuid,
|
||||||
processLanguages,
|
processLanguages,
|
||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
import { desc } from "~/models/utils/descriptions";
|
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"] })
|
export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
||||||
.model({
|
.model({
|
||||||
@ -30,11 +31,16 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
|||||||
set,
|
set,
|
||||||
}) => {
|
}) => {
|
||||||
const langs = processLanguages(languages);
|
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,
|
languages: langs,
|
||||||
|
fallbackLanguage: langs.includes("*"),
|
||||||
preferOriginal,
|
preferOriginal,
|
||||||
relations,
|
relations,
|
||||||
filters: eq(shows.kind, "serie"),
|
|
||||||
});
|
});
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
return error(404, {
|
return error(404, {
|
||||||
@ -49,7 +55,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
set.headers["content-language"] = ret.language;
|
set.headers["content-language"] = ret.language;
|
||||||
return ret.show;
|
return ret;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: {
|
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 Elysia, { t } from "elysia";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import {
|
import {
|
||||||
@ -7,7 +8,12 @@ import {
|
|||||||
studioTranslations,
|
studioTranslations,
|
||||||
studios,
|
studios,
|
||||||
} from "~/db/schema";
|
} from "~/db/schema";
|
||||||
import { getColumns, sqlarr } from "~/db/utils";
|
import {
|
||||||
|
getColumns,
|
||||||
|
jsonbBuildObject,
|
||||||
|
jsonbObjectAgg,
|
||||||
|
sqlarr,
|
||||||
|
} from "~/db/utils";
|
||||||
import { KError } from "~/models/error";
|
import { KError } from "~/models/error";
|
||||||
import { Movie } from "~/models/movie";
|
import { Movie } from "~/models/movie";
|
||||||
import { Serie } from "~/models/serie";
|
import { Serie } from "~/models/serie";
|
||||||
@ -18,11 +24,11 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Page,
|
Page,
|
||||||
Sort,
|
Sort,
|
||||||
|
buildRelations,
|
||||||
createPage,
|
createPage,
|
||||||
isUuid,
|
isUuid,
|
||||||
keysetPaginate,
|
keysetPaginate,
|
||||||
processLanguages,
|
processLanguages,
|
||||||
selectTranslationQuery,
|
|
||||||
sortToSql,
|
sortToSql,
|
||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
import { desc } from "~/models/utils/descriptions";
|
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 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"] })
|
export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||||
.model({
|
.model({
|
||||||
studio: Studio,
|
studio: Studio,
|
||||||
@ -45,21 +128,12 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
|||||||
set,
|
set,
|
||||||
}) => {
|
}) => {
|
||||||
const langs = processLanguages(languages);
|
const langs = processLanguages(languages);
|
||||||
const ret = await db.query.studios.findFirst({
|
const [ret] = await getStudios({
|
||||||
where: isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id),
|
limit: 1,
|
||||||
with: {
|
filter: isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id),
|
||||||
selectedTranslation: selectTranslationQuery(
|
languages: langs,
|
||||||
studioTranslations,
|
fallbackLanguage: langs.includes("*"),
|
||||||
langs,
|
relations,
|
||||||
),
|
|
||||||
...(relations.includes("translations") && {
|
|
||||||
translations: {
|
|
||||||
columns: {
|
|
||||||
pk: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
return error(404, {
|
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}'`,
|
message: `No studio with the id or slug: '${id}'`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const tr = ret.selectedTranslation[0];
|
if (!ret.language) {
|
||||||
set.headers["content-language"] = tr.language;
|
return error(422, {
|
||||||
return {
|
status: 422,
|
||||||
...ret,
|
message: "Accept-Language header could not be satisfied.",
|
||||||
...tr,
|
});
|
||||||
...(ret.translations && {
|
}
|
||||||
translations: Object.fromEntries(
|
set.headers["content-language"] = ret.language;
|
||||||
ret.translations.map(
|
return ret;
|
||||||
({ language, ...translation }) =>
|
|
||||||
[language, translation] as const,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: {
|
detail: {
|
||||||
@ -150,35 +218,13 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
|||||||
request: { url },
|
request: { url },
|
||||||
}) => {
|
}) => {
|
||||||
const langs = processLanguages(languages);
|
const langs = processLanguages(languages);
|
||||||
const transQ = db
|
const items = await getStudios({
|
||||||
.selectDistinctOn([studioTranslations.pk])
|
limit,
|
||||||
.from(studioTranslations)
|
after,
|
||||||
.orderBy(
|
query,
|
||||||
studioTranslations.pk,
|
sort,
|
||||||
sql`array_position(${sqlarr(langs)}, ${studioTranslations.language}`,
|
languages: langs,
|
||||||
)
|
});
|
||||||
.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);
|
|
||||||
return createPage(items, { url, sort, limit });
|
return createPage(items, { url, sort, limit });
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
date,
|
date,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
|
jsonb,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
smallint,
|
smallint,
|
||||||
text,
|
text,
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import type { Image, Original } from "~/models/utils";
|
||||||
import { entries } from "./entries";
|
import { entries } from "./entries";
|
||||||
import { seasons } from "./seasons";
|
import { seasons } from "./seasons";
|
||||||
import { showStudioJoin } from "./studios";
|
import { showStudioJoin } from "./studios";
|
||||||
@ -54,6 +56,13 @@ export const genres = schema.enum("genres", [
|
|||||||
"talk",
|
"talk",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
type OriginalWithImages = Original & {
|
||||||
|
poster: Image | null;
|
||||||
|
thumbnail: Image | null;
|
||||||
|
banner: Image | null;
|
||||||
|
logo: Image | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const shows = schema.table(
|
export const shows = schema.table(
|
||||||
"shows",
|
"shows",
|
||||||
{
|
{
|
||||||
@ -67,7 +76,7 @@ export const shows = schema.table(
|
|||||||
status: showStatus().notNull(),
|
status: showStatus().notNull(),
|
||||||
startAir: date(),
|
startAir: date(),
|
||||||
endAir: date(),
|
endAir: date(),
|
||||||
originalLanguage: language(),
|
original: jsonb().$type<OriginalWithImages>().notNull(),
|
||||||
|
|
||||||
collectionPk: integer().references((): AnyPgColumn => shows.pk, {
|
collectionPk: integer().references((): AnyPgColumn => shows.pk, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
@ -120,16 +129,8 @@ export const showTranslations = schema.table(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const showsRelations = relations(shows, ({ many, one }) => ({
|
export const showsRelations = relations(shows, ({ many }) => ({
|
||||||
selectedTranslation: many(showTranslations, {
|
|
||||||
relationName: "selected_translation",
|
|
||||||
}),
|
|
||||||
translations: many(showTranslations, { relationName: "show_translations" }),
|
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" }),
|
entries: many(entries, { relationName: "show_entries" }),
|
||||||
seasons: many(seasons, { relationName: "show_seasons" }),
|
seasons: many(seasons, { relationName: "show_seasons" }),
|
||||||
studios: many(showStudioJoin, { relationName: "ssj_show" }),
|
studios: many(showStudioJoin, { relationName: "ssj_show" }),
|
||||||
@ -140,14 +141,4 @@ export const showsTrRelations = relations(showTranslations, ({ one }) => ({
|
|||||||
fields: [showTranslations.pk],
|
fields: [showTranslations.pk],
|
||||||
references: [shows.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 { jsonb, pgSchema, varchar } from "drizzle-orm/pg-core";
|
||||||
|
import type { Image } from "~/models/utils";
|
||||||
|
|
||||||
export const schema = pgSchema("kyoo");
|
export const schema = pgSchema("kyoo");
|
||||||
|
|
||||||
export const language = () => varchar({ length: 255 });
|
export const language = () => varchar({ length: 255 });
|
||||||
|
|
||||||
export const image = () =>
|
export const image = () => jsonb().$type<Image>();
|
||||||
jsonb().$type<{ id: string; source: string; blurhash: string }>();
|
|
||||||
|
|
||||||
export const externalid = () =>
|
export const externalid = () =>
|
||||||
jsonb()
|
jsonb()
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import type { Guess } from "~/models/video";
|
||||||
import { entries } from "./entries";
|
import { entries } from "./entries";
|
||||||
import { schema } from "./utils";
|
import { schema } from "./utils";
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ export const videos = schema.table(
|
|||||||
rendering: text().notNull(),
|
rendering: text().notNull(),
|
||||||
part: integer(),
|
part: integer(),
|
||||||
version: integer().notNull().default(1),
|
version: integer().notNull().default(1),
|
||||||
guess: jsonb().notNull().default({}),
|
guess: jsonb().$type<Guess>().notNull(),
|
||||||
|
|
||||||
createdAt: timestamp({ withTimezone: true, mode: "string" })
|
createdAt: timestamp({ withTimezone: true, mode: "string" })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type ColumnsSelection,
|
type ColumnsSelection,
|
||||||
|
InferColumnsDataTypes,
|
||||||
type SQL,
|
type SQL,
|
||||||
|
type SQLWrapper,
|
||||||
type Subquery,
|
type Subquery,
|
||||||
Table,
|
Table,
|
||||||
View,
|
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,
|
Genre,
|
||||||
Image,
|
Image,
|
||||||
Language,
|
Language,
|
||||||
|
Original,
|
||||||
Resource,
|
Resource,
|
||||||
SeedImage,
|
SeedImage,
|
||||||
TranslationRecord,
|
TranslationRecord,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
const BaseCollection = t.Object({
|
const BaseCollection = t.Object({
|
||||||
kind: t.Literal("collection"),
|
|
||||||
genres: t.Array(Genre),
|
genres: t.Array(Genre),
|
||||||
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
||||||
startAir: t.Nullable(
|
startAir: t.Nullable(
|
||||||
@ -28,14 +28,7 @@ const BaseCollection = t.Object({
|
|||||||
descrpition: "Date of the last item of the collection",
|
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" }),
|
nextRefresh: t.String({ format: "date-time" }),
|
||||||
|
|
||||||
externalId: ExternalId(),
|
externalId: ExternalId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,6 +50,9 @@ export const Collection = t.Intersect([
|
|||||||
CollectionTranslation,
|
CollectionTranslation,
|
||||||
BaseCollection,
|
BaseCollection,
|
||||||
DbMetadata,
|
DbMetadata,
|
||||||
|
t.Object({
|
||||||
|
original: Original,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
export type Collection = Prettify<typeof Collection.static>;
|
export type Collection = Prettify<typeof Collection.static>;
|
||||||
|
|
||||||
@ -72,6 +68,9 @@ export const SeedCollection = t.Intersect([
|
|||||||
t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]),
|
t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]),
|
||||||
t.Object({
|
t.Object({
|
||||||
slug: t.String({ format: "slug" }),
|
slug: t.String({ format: "slug" }),
|
||||||
|
originalLanguage: Language({
|
||||||
|
description: "The language code this collection's items were made in.",
|
||||||
|
}),
|
||||||
translations: TranslationRecord(
|
translations: TranslationRecord(
|
||||||
t.Intersect([
|
t.Intersect([
|
||||||
t.Omit(CollectionTranslation, [
|
t.Omit(CollectionTranslation, [
|
||||||
@ -85,6 +84,7 @@ export const SeedCollection = t.Intersect([
|
|||||||
thumbnail: t.Nullable(SeedImage),
|
thumbnail: t.Nullable(SeedImage),
|
||||||
banner: t.Nullable(SeedImage),
|
banner: t.Nullable(SeedImage),
|
||||||
logo: t.Nullable(SeedImage),
|
logo: t.Nullable(SeedImage),
|
||||||
|
latinName: t.Optional(Original.properties.latinName),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
@ -8,6 +8,12 @@ export const bubbleVideo: Video = {
|
|||||||
rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd",
|
rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd",
|
||||||
part: null,
|
part: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
guess: {
|
||||||
|
kind: "movie",
|
||||||
|
title: "bubble",
|
||||||
|
year: [2022],
|
||||||
|
from: "guessit",
|
||||||
|
},
|
||||||
createdAt: "2024-11-23T15:01:24.968Z",
|
createdAt: "2024-11-23T15:01:24.968Z",
|
||||||
updatedAt: "2024-11-23T15:01:24.968Z",
|
updatedAt: "2024-11-23T15:01:24.968Z",
|
||||||
};
|
};
|
||||||
@ -32,6 +38,7 @@ export const bubble: SeedMovie = {
|
|||||||
},
|
},
|
||||||
ja: {
|
ja: {
|
||||||
name: "バブル:2022",
|
name: "バブル:2022",
|
||||||
|
latinName: "Buburu",
|
||||||
tagline: null,
|
tagline: null,
|
||||||
description: null,
|
description: null,
|
||||||
aliases: ["Baburu", "Bubble"],
|
aliases: ["Baburu", "Bubble"],
|
||||||
|
@ -8,6 +8,12 @@ export const dune1984Video: Video = {
|
|||||||
rendering: "ea3a0f8f2f2c5b61a07f61e4e8d9f8e01b2b92bcbb6f5ed1151e1f61619c2c0f",
|
rendering: "ea3a0f8f2f2c5b61a07f61e4e8d9f8e01b2b92bcbb6f5ed1151e1f61619c2c0f",
|
||||||
part: null,
|
part: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
guess: {
|
||||||
|
kind: "movie",
|
||||||
|
title: "dune",
|
||||||
|
year: [1984],
|
||||||
|
from: "guessit",
|
||||||
|
},
|
||||||
createdAt: "2024-12-02T11:45:12.968Z",
|
createdAt: "2024-12-02T11:45:12.968Z",
|
||||||
updatedAt: "2024-12-02T11:45:12.968Z",
|
updatedAt: "2024-12-02T11:45:12.968Z",
|
||||||
};
|
};
|
||||||
|
@ -8,6 +8,12 @@ export const duneVideo: Video = {
|
|||||||
rendering: "f1953a4fb58247efb6c15b76468b6a9d13b4155b02094863b1a4f0c3fbb6db58",
|
rendering: "f1953a4fb58247efb6c15b76468b6a9d13b4155b02094863b1a4f0c3fbb6db58",
|
||||||
part: null,
|
part: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
guess: {
|
||||||
|
kind: "movie",
|
||||||
|
title: "dune",
|
||||||
|
year: [2021],
|
||||||
|
from: "guessit",
|
||||||
|
},
|
||||||
createdAt: "2024-12-02T10:10:24.968Z",
|
createdAt: "2024-12-02T10:10:24.968Z",
|
||||||
updatedAt: "2024-12-02T10:10:24.968Z",
|
updatedAt: "2024-12-02T10:10:24.968Z",
|
||||||
};
|
};
|
||||||
|
@ -60,6 +60,40 @@ export const madeInAbyss = {
|
|||||||
banner: null,
|
banner: null,
|
||||||
trailerUrl: "https://www.youtube.com/watch?v=ePOyy6Wlk4s",
|
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: [
|
genres: [
|
||||||
"animation",
|
"animation",
|
||||||
|
@ -13,29 +13,21 @@ import {
|
|||||||
SeedImage,
|
SeedImage,
|
||||||
TranslationRecord,
|
TranslationRecord,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { Original } from "./utils/original";
|
||||||
import { Video } from "./video";
|
import { Video } from "./video";
|
||||||
|
|
||||||
export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]);
|
export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]);
|
||||||
export type MovieStatus = typeof MovieStatus.static;
|
export type MovieStatus = typeof MovieStatus.static;
|
||||||
|
|
||||||
const BaseMovie = t.Object({
|
const BaseMovie = t.Object({
|
||||||
kind: t.Literal("movie"),
|
|
||||||
genres: t.Array(Genre),
|
genres: t.Array(Genre),
|
||||||
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
||||||
status: MovieStatus,
|
status: MovieStatus,
|
||||||
runtime: t.Nullable(
|
runtime: t.Nullable(
|
||||||
t.Number({ minimum: 0, description: "Runtime of the movie in minutes." }),
|
t.Number({ minimum: 0, description: "Runtime of the movie in minutes." }),
|
||||||
),
|
),
|
||||||
|
|
||||||
airDate: t.Nullable(t.String({ format: "date" })),
|
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" }),
|
nextRefresh: t.String({ format: "date-time" }),
|
||||||
|
|
||||||
externalId: ExternalId(),
|
externalId: ExternalId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -60,6 +52,7 @@ export const Movie = t.Intersect([
|
|||||||
BaseMovie,
|
BaseMovie,
|
||||||
DbMetadata,
|
DbMetadata,
|
||||||
t.Object({
|
t.Object({
|
||||||
|
original: Original,
|
||||||
isAvailable: t.Boolean(),
|
isAvailable: t.Boolean(),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@ -79,6 +72,9 @@ export const SeedMovie = t.Intersect([
|
|||||||
t.Omit(BaseMovie, ["kind", "nextRefresh"]),
|
t.Omit(BaseMovie, ["kind", "nextRefresh"]),
|
||||||
t.Object({
|
t.Object({
|
||||||
slug: t.String({ format: "slug", examples: ["bubble"] }),
|
slug: t.String({ format: "slug", examples: ["bubble"] }),
|
||||||
|
originalLanguage: Language({
|
||||||
|
description: "The language code this movie was made in.",
|
||||||
|
}),
|
||||||
translations: TranslationRecord(
|
translations: TranslationRecord(
|
||||||
t.Intersect([
|
t.Intersect([
|
||||||
t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]),
|
t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]),
|
||||||
@ -87,6 +83,7 @@ export const SeedMovie = t.Intersect([
|
|||||||
thumbnail: t.Nullable(SeedImage),
|
thumbnail: t.Nullable(SeedImage),
|
||||||
banner: t.Nullable(SeedImage),
|
banner: t.Nullable(SeedImage),
|
||||||
logo: t.Nullable(SeedImage),
|
logo: t.Nullable(SeedImage),
|
||||||
|
latinName: t.Optional(Original.properties.latinName),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
SeedImage,
|
SeedImage,
|
||||||
TranslationRecord,
|
TranslationRecord,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { Original } from "./utils/original";
|
||||||
|
|
||||||
export const SerieStatus = t.UnionEnum([
|
export const SerieStatus = t.UnionEnum([
|
||||||
"unknown",
|
"unknown",
|
||||||
@ -25,7 +26,6 @@ export const SerieStatus = t.UnionEnum([
|
|||||||
export type SerieStatus = typeof SerieStatus.static;
|
export type SerieStatus = typeof SerieStatus.static;
|
||||||
|
|
||||||
const BaseSerie = t.Object({
|
const BaseSerie = t.Object({
|
||||||
kind: t.Literal("serie"),
|
|
||||||
genres: t.Array(Genre),
|
genres: t.Array(Genre),
|
||||||
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
|
||||||
status: SerieStatus,
|
status: SerieStatus,
|
||||||
@ -35,23 +35,9 @@ const BaseSerie = t.Object({
|
|||||||
description: "Average runtime of all episodes (in minutes.)",
|
description: "Average runtime of all episodes (in minutes.)",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
startAir: t.Nullable(t.String({ format: "date" })),
|
startAir: t.Nullable(t.String({ format: "date" })),
|
||||||
endAir: 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" }),
|
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(),
|
externalId: ExternalId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -75,6 +61,15 @@ export const Serie = t.Intersect([
|
|||||||
SerieTranslation,
|
SerieTranslation,
|
||||||
BaseSerie,
|
BaseSerie,
|
||||||
DbMetadata,
|
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>;
|
export type Serie = Prettify<typeof Serie.static>;
|
||||||
|
|
||||||
@ -88,9 +83,12 @@ export const FullSerie = t.Intersect([
|
|||||||
export type FullMovie = Prettify<typeof FullSerie.static>;
|
export type FullMovie = Prettify<typeof FullSerie.static>;
|
||||||
|
|
||||||
export const SeedSerie = t.Intersect([
|
export const SeedSerie = t.Intersect([
|
||||||
t.Omit(BaseSerie, ["kind", "nextRefresh", "entriesCount", "availableCount"]),
|
t.Omit(BaseSerie, ["kind", "nextRefresh"]),
|
||||||
t.Object({
|
t.Object({
|
||||||
slug: t.String({ format: "slug" }),
|
slug: t.String({ format: "slug" }),
|
||||||
|
originalLanguage: Language({
|
||||||
|
description: "The language code this serie was made in.",
|
||||||
|
}),
|
||||||
translations: TranslationRecord(
|
translations: TranslationRecord(
|
||||||
t.Intersect([
|
t.Intersect([
|
||||||
t.Omit(SerieTranslation, ["poster", "thumbnail", "banner", "logo"]),
|
t.Omit(SerieTranslation, ["poster", "thumbnail", "banner", "logo"]),
|
||||||
@ -99,6 +97,7 @@ export const SeedSerie = t.Intersect([
|
|||||||
thumbnail: t.Nullable(SeedImage),
|
thumbnail: t.Nullable(SeedImage),
|
||||||
banner: t.Nullable(SeedImage),
|
banner: t.Nullable(SeedImage),
|
||||||
logo: 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 { Movie } from "./movie";
|
||||||
import { Serie } from "./serie";
|
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(),
|
name: t.String(),
|
||||||
logo: t.Nullable(Image),
|
logo: t.Nullable(Image),
|
||||||
});
|
});
|
||||||
|
export type StudioTranslation = typeof StudioTranslation.static;
|
||||||
|
|
||||||
export const Studio = t.Intersect([
|
export const Studio = t.Intersect([
|
||||||
Resource(),
|
Resource(),
|
||||||
|
@ -8,3 +8,5 @@ export * from "./page";
|
|||||||
export * from "./sort";
|
export * from "./sort";
|
||||||
export * from "./keyset-paginate";
|
export * from "./keyset-paginate";
|
||||||
export * from "./db-metadata";
|
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"]>;
|
table: Table<"pk" | Sort<T, Remap>["sort"][number]["key"]>;
|
||||||
after: string | undefined;
|
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(
|
const cursor: After = JSON.parse(
|
||||||
Buffer.from(after, "base64").toString("utf-8"),
|
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).
|
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.
|
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"],
|
examples: ["en-US"],
|
||||||
...props,
|
...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[],
|
T extends string[],
|
||||||
Remap extends Partial<Record<T[number], 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">,
|
table: Table<Sort<T, Remap>["sort"][number]["key"] | "pk">,
|
||||||
) => {
|
) => {
|
||||||
|
if (!sort) return [];
|
||||||
if (sort.random) {
|
if (sort.random) {
|
||||||
return [sql`md5(${sort.random.seed} || ${table.pk})`];
|
return [sql`md5(${sort.random.seed} || ${table.pk})`];
|
||||||
}
|
}
|
||||||
|
@ -3,34 +3,7 @@ import { type Prettify, comment } from "~/utils";
|
|||||||
import { bubbleVideo, registerExamples } from "./examples";
|
import { bubbleVideo, registerExamples } from "./examples";
|
||||||
import { DbMetadata, Resource } from "./utils";
|
import { DbMetadata, Resource } from "./utils";
|
||||||
|
|
||||||
export const SeedVideo = t.Object({
|
export const Guess = t.Recursive((Self) =>
|
||||||
path: t.String(),
|
|
||||||
rendering: t.String({
|
|
||||||
description: comment`
|
|
||||||
Sha of the path except \`part\` & \`version\`.
|
|
||||||
If there are multiples files for the same entry, it can be used to know if each
|
|
||||||
file is the same content or if it's unrelated (like long-version vs short-version, monochrome vs colored etc)
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
part: t.Nullable(
|
|
||||||
t.Integer({
|
|
||||||
minimum: 0,
|
|
||||||
description: comment`
|
|
||||||
If the episode/movie is split into multiples files, the \`part\` field can be used to order them.
|
|
||||||
The \`rendering\` field is used to know if two parts are in the same group or
|
|
||||||
if it's another unrelated video file of the same entry.
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
version: t.Integer({
|
|
||||||
minimum: 0,
|
|
||||||
default: 1,
|
|
||||||
description:
|
|
||||||
"Kyoo will prefer playing back the highest `version` number if there are multiples rendering.",
|
|
||||||
}),
|
|
||||||
|
|
||||||
guess: t.Optional(
|
|
||||||
t.Recursive((Self) =>
|
|
||||||
t.Object(
|
t.Object(
|
||||||
{
|
{
|
||||||
title: t.String(),
|
title: t.String(),
|
||||||
@ -63,8 +36,36 @@ export const SeedVideo = t.Object({
|
|||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
export type Guess = typeof Guess.static;
|
||||||
|
|
||||||
|
export const SeedVideo = t.Object({
|
||||||
|
path: t.String(),
|
||||||
|
rendering: t.String({
|
||||||
|
description: comment`
|
||||||
|
Sha of the path except \`part\` & \`version\`.
|
||||||
|
If there are multiples files for the same entry, it can be used to know if each
|
||||||
|
file is the same content or if it's unrelated (like long-version vs short-version, monochrome vs colored etc)
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
part: t.Nullable(
|
||||||
|
t.Integer({
|
||||||
|
minimum: 0,
|
||||||
|
description: comment`
|
||||||
|
If the episode/movie is split into multiples files, the \`part\` field can be used to order them.
|
||||||
|
The \`rendering\` field is used to know if two parts are in the same group or
|
||||||
|
if it's another unrelated video file of the same entry.
|
||||||
|
`,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
version: t.Integer({
|
||||||
|
minimum: 0,
|
||||||
|
default: 1,
|
||||||
|
description:
|
||||||
|
"Kyoo will prefer playing back the highest `version` number if there are multiples rendering.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
guess: Guess,
|
||||||
});
|
});
|
||||||
export type SeedVideo = typeof SeedVideo.static;
|
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>;
|
export type Video = Prettify<typeof Video.static>;
|
||||||
|
|
||||||
// type used in entry responses
|
// 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>;
|
export type EmbeddedVideo = Prettify<typeof EmbeddedVideo.static>;
|
||||||
|
|
||||||
registerExamples(Video, bubbleVideo);
|
registerExamples(Video, bubbleVideo);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { db, migrate } from "~/db";
|
import { db, migrate } from "~/db";
|
||||||
import { shows, videos } from "~/db/schema";
|
import { shows, videos } from "~/db/schema";
|
||||||
import { madeInAbyss, madeInAbyssVideo } from "~/models/examples";
|
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`
|
// test file used to run manually using `bun tests/manual.ts`
|
||||||
|
|
||||||
@ -13,3 +13,5 @@ const [_, vid] = await createVideo(madeInAbyssVideo);
|
|||||||
console.log(vid);
|
console.log(vid);
|
||||||
const [__, ser] = await createSerie(madeInAbyss);
|
const [__, ser] = await createSerie(madeInAbyss);
|
||||||
console.log(ser);
|
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 { beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { expectStatus } from "tests/utils";
|
import { expectStatus } from "tests/utils";
|
||||||
import { seedMovie } from "~/controllers/seed/movies";
|
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { shows } from "~/db/schema";
|
import { shows } from "~/db/schema";
|
||||||
import { bubble } from "~/models/examples";
|
import { bubble } from "~/models/examples";
|
||||||
@ -10,7 +9,10 @@ import { app, createMovie, getMovies } from "../helpers";
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await db.delete(shows);
|
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", () => {
|
describe("with a null value", () => {
|
||||||
@ -39,7 +41,7 @@ describe("with a null value", () => {
|
|||||||
rating: null,
|
rating: null,
|
||||||
runtime: null,
|
runtime: null,
|
||||||
airDate: null,
|
airDate: null,
|
||||||
originalLanguage: null,
|
originalLanguage: "en",
|
||||||
externalId: {},
|
externalId: {},
|
||||||
studios: [],
|
studios: [],
|
||||||
});
|
});
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
import { beforeAll, describe, expect, it } from "bun:test";
|
import { beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { expectStatus } from "tests/utils";
|
import { expectStatus } from "tests/utils";
|
||||||
import { seedMovie } from "~/controllers/seed/movies";
|
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { shows } from "~/db/schema";
|
import { shows } from "~/db/schema";
|
||||||
import { bubble } from "~/models/examples";
|
import { bubble } from "~/models/examples";
|
||||||
import { dune1984 } from "~/models/examples/dune-1984";
|
import { dune1984 } from "~/models/examples/dune-1984";
|
||||||
import { dune } from "~/models/examples/dune-2021";
|
import { dune } from "~/models/examples/dune-2021";
|
||||||
import type { Movie } from "~/models/movie";
|
import type { Movie } from "~/models/movie";
|
||||||
import { isUuid } from "~/models/utils";
|
import { app, createMovie, getMovies } from "../helpers";
|
||||||
import { app, getMovies } from "../helpers";
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await db.delete(shows);
|
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", () => {
|
describe("Get all movies", () => {
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import { beforeAll, describe, expect, it } from "bun:test";
|
import { beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { expectStatus } from "tests/utils";
|
import { expectStatus } from "tests/utils";
|
||||||
import { seedMovie } from "~/controllers/seed/movies";
|
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { shows, videos } from "~/db/schema";
|
import { shows, videos } from "~/db/schema";
|
||||||
import { bubble, bubbleVideo } from "~/models/examples";
|
import { bubble, bubbleVideo } from "~/models/examples";
|
||||||
import { getMovie } from "../helpers";
|
import { createMovie, getMovie } from "../helpers";
|
||||||
|
|
||||||
let bubbleId = "";
|
let bubbleId = "";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await db.delete(shows);
|
await db.delete(shows);
|
||||||
await db.insert(videos).values(bubbleVideo);
|
await db.insert(videos).values(bubbleVideo);
|
||||||
const ret = await seedMovie(bubble);
|
const [ret, body] = await createMovie(bubble);
|
||||||
if (!("status" in ret)) bubbleId = ret.id;
|
expect(ret.status).toBe(201);
|
||||||
|
bubbleId = body.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Get movie", () => {
|
describe("Get movie", () => {
|
||||||
@ -124,7 +124,7 @@ describe("Get movie", () => {
|
|||||||
expect(body.isAvailable).toBe(true);
|
expect(body.isAvailable).toBe(true);
|
||||||
});
|
});
|
||||||
it("With isAvailable=false", async () => {
|
it("With isAvailable=false", async () => {
|
||||||
await seedMovie({
|
await createMovie({
|
||||||
...bubble,
|
...bubble,
|
||||||
slug: "no-video",
|
slug: "no-video",
|
||||||
videos: [],
|
videos: [],
|
||||||
|
@ -168,7 +168,7 @@ describe("Movie seeding", () => {
|
|||||||
const [resp, body] = await createMovie({
|
const [resp, body] = await createMovie({
|
||||||
...bubble,
|
...bubble,
|
||||||
slug: "casing-test",
|
slug: "casing-test",
|
||||||
originalLanguage: "jp-jp",
|
originalLanguage: "en-us",
|
||||||
translations: {
|
translations: {
|
||||||
"en-us": {
|
"en-us": {
|
||||||
name: "foo",
|
name: "foo",
|
||||||
@ -191,7 +191,7 @@ describe("Movie seeding", () => {
|
|||||||
where: eq(shows.id, body.id),
|
where: eq(shows.id, body.id),
|
||||||
with: { translations: true },
|
with: { translations: true },
|
||||||
});
|
});
|
||||||
expect(ret!.originalLanguage).toBe("jp-JP");
|
expect(ret!.original.language).toBe("en-US");
|
||||||
expect(ret!.translations).toBeArrayOfSize(2);
|
expect(ret!.translations).toBeArrayOfSize(2);
|
||||||
expect(ret!.translations).toEqual(
|
expect(ret!.translations).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@ -229,7 +229,10 @@ describe("Movie seeding", () => {
|
|||||||
const [resp, body] = await createMovie({
|
const [resp, body] = await createMovie({
|
||||||
...bubble,
|
...bubble,
|
||||||
slug: "bubble-translation-test",
|
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);
|
expectStatus(resp, body).toBe(201);
|
||||||
|
|
||||||
@ -262,6 +265,7 @@ describe("Movie seeding", () => {
|
|||||||
"en-us": bubble.translations.en,
|
"en-us": bubble.translations.en,
|
||||||
"en-au": { ...bubble.translations.en, name: "australian thing" },
|
"en-au": { ...bubble.translations.en, name: "australian thing" },
|
||||||
en: { ...bubble.translations.en, name: "Generic" },
|
en: { ...bubble.translations.en, name: "Generic" },
|
||||||
|
ja: bubble.translations.ja,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expectStatus(resp, body).toBe(201);
|
expectStatus(resp, body).toBe(201);
|
||||||
@ -304,6 +308,7 @@ describe("Movie seeding", () => {
|
|||||||
part: null,
|
part: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
rendering: "oeunhtoeuth",
|
rendering: "oeunhtoeuth",
|
||||||
|
guess: { title: "bubble", from: "test" },
|
||||||
});
|
});
|
||||||
expectStatus(vresp, video).toBe(201);
|
expectStatus(vresp, video).toBe(201);
|
||||||
|
|
||||||
@ -329,6 +334,7 @@ describe("Movie seeding", () => {
|
|||||||
part: null,
|
part: null,
|
||||||
version: 2,
|
version: 2,
|
||||||
rendering: "oeunhtoeuth",
|
rendering: "oeunhtoeuth",
|
||||||
|
guess: { title: "bubble", from: "test" },
|
||||||
});
|
});
|
||||||
expectStatus(vresp, video).toBe(201);
|
expectStatus(vresp, video).toBe(201);
|
||||||
|
|
||||||
@ -353,6 +359,7 @@ describe("Movie seeding", () => {
|
|||||||
part: 1,
|
part: 1,
|
||||||
version: 2,
|
version: 2,
|
||||||
rendering: "oaoeueunhtoeuth",
|
rendering: "oaoeueunhtoeuth",
|
||||||
|
guess: { title: "bubble", from: "test" },
|
||||||
});
|
});
|
||||||
expectStatus(vresp, video).toBe(201);
|
expectStatus(vresp, video).toBe(201);
|
||||||
|
|
||||||
@ -378,12 +385,14 @@ describe("Movie seeding", () => {
|
|||||||
part: null,
|
part: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
rendering: "oeunhtoeuth",
|
rendering: "oeunhtoeuth",
|
||||||
|
guess: { title: "bubble", from: "test" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/video/bubble4.mkv",
|
path: "/video/bubble4.mkv",
|
||||||
part: null,
|
part: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
rendering: "aoeuaoeu",
|
rendering: "aoeuaoeu",
|
||||||
|
guess: { title: "bubble", from: "test" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expectStatus(vresp, video).toBe(201);
|
expectStatus(vresp, video).toBe(201);
|
||||||
|
@ -12,7 +12,7 @@ beforeAll(async () => {
|
|||||||
await createSerie(madeInAbyss);
|
await createSerie(madeInAbyss);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("aet series", () => {
|
describe("Get series", () => {
|
||||||
it("Invalid slug", async () => {
|
it("Invalid slug", async () => {
|
||||||
const [resp, body] = await getSerie("sotneuhn", { langs: "en" });
|
const [resp, body] = await getSerie("sotneuhn", { langs: "en" });
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user