diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index c4138b10..7e1ac674 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -9,6 +9,7 @@ import { history, profiles, shows, + showTranslations, videos, } from "~/db/schema"; import { @@ -16,11 +17,13 @@ import { getColumns, jsonbAgg, jsonbBuildObject, + jsonbObjectAgg, sqlarr, } from "~/db/utils"; import { Entry, type EntryKind, + type EntryTranslation, Episode, Extra, ExtraType, @@ -29,8 +32,12 @@ import { } from "~/models/entry"; import { KError } from "~/models/error"; import { madeInAbyss } from "~/models/examples"; +import { Movie } from "~/models/movie"; +import { Show } from "~/models/show"; +import type { Image } from "~/models/utils"; import { AcceptLanguage, + buildRelations, createPage, Filter, type FilterDef, @@ -43,6 +50,7 @@ import { } from "~/models/utils"; import { desc as description } from "~/models/utils/descriptions"; import type { EmbeddedVideo } from "~/models/video"; +import { watchStatusQ } from "./shows/logic"; export const entryProgressQ = db .selectDistinctOn([history.entryPk], { @@ -123,6 +131,70 @@ const newsSort: Sort = { }, ], }; + +const entryRelations = { + translations: () => { + const { pk, language, ...trans } = getColumns(entryTranslations); + return db + .select({ + json: jsonbObjectAgg( + language, + jsonbBuildObject(trans), + ).as("json"), + }) + .from(entryTranslations) + .where(eq(entryTranslations.pk, entries.pk)) + .as("translations"); + }, + show: ({ + languages, + preferOriginal, + }: { + languages: string[]; + preferOriginal: boolean; + }) => { + const transQ = db + .selectDistinctOn([showTranslations.pk]) + .from(showTranslations) + .orderBy( + showTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, + ) + .as("t"); + + const watchStatus = sql` + case + when ${watchStatusQ.showPk} is null then null + else (${jsonbBuildObject(getColumns(watchStatusQ))}) + end + `; + + return db + .select({ + json: jsonbBuildObject({ + ...getColumns(shows), + airDate: shows.startAir, + isAvailable: sql`${shows.availableCount} != 0`, + ...getColumns(transQ), + + ...(preferOriginal && { + poster: sql`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`, + thumbnail: sql`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`, + banner: sql`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`, + logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, + }), + + watchStatus, + }).as("json"), + }) + .from(shows) + .innerJoin(transQ, eq(shows.pk, transQ.pk)) + .leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk)) + .where(eq(shows.pk, entries.showPk)) + .as("entry_show"); + }, +}; + const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos); export const entryVideosQ = db .select({ @@ -175,6 +247,8 @@ export async function getEntries({ languages, userId, progressQ = entryProgressQ, + relations = [], + preferOriginal = false, }: { after: string | undefined; limit: number; @@ -184,6 +258,8 @@ export async function getEntries({ languages: string[]; userId: string; progressQ?: typeof entryProgressQ; + relations?: (keyof typeof entryRelations)[]; + preferOriginal?: boolean; }): Promise<(Entry | Extra)[]> { const transQ = getEntryTransQ(languages); @@ -216,6 +292,11 @@ export async function getEntries({ seasonNumber: sql`${seasonNumber}`, episodeNumber: sql`${episodeNumber}`, name: sql`${transQ.name}`, + + ...buildRelations(relations, entryRelations, { + languages, + preferOriginal, + }), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) @@ -412,7 +493,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: { limit, after, query, filter }, request: { url }, headers, - jwt: { sub }, + jwt: { sub, settings }, }) => { const sort = newsSort; const items = (await getEntries({ @@ -427,7 +508,9 @@ export const entriesH = new Elysia({ tags: ["series"] }) ), languages: ["extra"], userId: sub, - })) as Entry[]; + relations: ["show"], + preferOriginal: settings.preferOriginal, + })) as (Entry & { show: Show })[]; return createPage(items, { url, sort, limit, headers }); }, @@ -445,7 +528,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) after: t.Optional(t.String({ description: description.after })), }), response: { - 200: Page(Entry), + 200: Page(t.Intersect([Entry, t.Object({ show: Show })])), 422: KError, }, tags: ["shows"], diff --git a/api/src/models/entry/index.ts b/api/src/models/entry/index.ts index 662691e2..17b5ff02 100644 --- a/api/src/models/entry/index.ts +++ b/api/src/models/entry/index.ts @@ -1,7 +1,12 @@ import { t } from "elysia"; +import { EntryTranslation as BaseEntryTranslation } from "./base-entry"; import { Episode, SeedEpisode } from "./episode"; import type { Extra } from "./extra"; -import { MovieEntry, SeedMovieEntry } from "./movie-entry"; +import { + MovieEntry, + MovieEntryTranslation, + SeedMovieEntry, +} from "./movie-entry"; import { SeedSpecial, Special } from "./special"; export const Entry = t.Union([Episode, MovieEntry, Special]); @@ -12,6 +17,12 @@ export type SeedEntry = SeedEpisode | SeedMovieEntry | SeedSpecial; export type EntryKind = Entry["kind"] | Extra["kind"]; +export const EntryTranslation = t.Union([ + BaseEntryTranslation(), + MovieEntryTranslation, +]); +export type EntryTranslation = typeof EntryTranslation.static; + export * from "./episode"; export * from "./extra"; export * from "./movie-entry"; diff --git a/front/src/models/entry.ts b/front/src/models/entry.ts index fd3c8ba5..37bdf7c0 100644 --- a/front/src/models/entry.ts +++ b/front/src/models/entry.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { Show } from "./show"; import { KImage } from "./utils/images"; import { Metadata } from "./utils/metadata"; import { zdate } from "./utils/utils"; @@ -33,27 +34,6 @@ const Base = z.object({ playedDate: zdate().nullable(), videoId: z.string().nullable(), }), - // Optional fields for API responses - serie: z - .object({ - id: z.string(), - slug: z.string(), - name: z.string(), - }) - .optional(), - watchStatus: z - .object({ - status: z.enum([ - "completed", - "watching", - "rewatching", - "dropped", - "planned", - ]), - percent: z.number().int().gte(0).lte(100), - }) - .nullable() - .optional(), }); export const Episode = Base.extend({ @@ -95,11 +75,19 @@ export const Special = Base.extend({ }); export type Special = z.infer; -export const Entry = z +export const BaseEntry = z .discriminatedUnion("kind", [Episode, MovieEntry, Special]) .transform((x) => ({ ...x, // TODO: don't just pick the first video, be smart about it href: x.videos.length ? `/watch/${x.videos[0].slug}` : null, })); + +export const Entry = BaseEntry.and( + z.object({ + get show() { + return Show.optional(); + }, + }), +); export type Entry = z.infer; diff --git a/front/src/models/serie.ts b/front/src/models/serie.ts index f4a12468..e8671382 100644 --- a/front/src/models/serie.ts +++ b/front/src/models/serie.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { Entry } from "./entry"; +import { BaseEntry } from "./entry"; import { Studio } from "./studio"; import { Genre } from "./utils/genre"; import { KImage } from "./utils/images"; @@ -41,8 +41,12 @@ export const Serie = z updatedAt: zdate(), studios: z.array(Studio).optional(), - firstEntry: Entry.optional().nullable(), - nextEntry: Entry.optional().nullable(), + get firstEntry() { + return BaseEntry.optional().nullable(); + }, + get nextEntry() { + return BaseEntry.optional().nullable(); + }, watchStatus: z .object({ status: z.enum([ diff --git a/front/src/ui/home/news.tsx b/front/src/ui/home/news.tsx index ed049552..6f16347b 100644 --- a/front/src/ui/home/news.tsx +++ b/front/src/ui/home/news.tsx @@ -25,12 +25,12 @@ export const NewsList = () => { return ( ); // }