Add shows in /news api

This commit is contained in:
Zoe Roux 2026-01-03 11:58:21 +01:00
parent 8f71099e7e
commit 54e067442f
No known key found for this signature in database
5 changed files with 118 additions and 32 deletions

View File

@ -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<EntryTranslation>(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<Show>({
...getColumns(shows),
airDate: shows.startAir,
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
...getColumns(transQ),
...(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})`,
}),
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<number>`${seasonNumber}`,
episodeNumber: sql<number>`${episodeNumber}`,
name: sql<string>`${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"],

View File

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

View File

@ -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<typeof Special>;
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<typeof Entry>;

View File

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

View File

@ -25,12 +25,12 @@ export const NewsList = () => {
return (
<EntryBox
slug={item.slug}
serieSlug={item.slug}
name={`${item.name} ${entryDisplayNumber(item)}`}
serieSlug={item.show!.slug}
name={`${item.show!.name} ${entryDisplayNumber(item)}`}
description={item.name}
thumbnail={item.thumbnail}
href={item.href ?? "#"}
watchedPercent={item.watchStatus?.percent || null}
watchedPercent={item.progress.percent}
/>
);
// }