diff --git a/api/src/base.ts b/api/src/base.ts index ca88e897..b4e285ef 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -3,6 +3,7 @@ import { auth } from "./auth"; import { entriesH } from "./controllers/entries"; import { imagesH } from "./controllers/images"; import { historyH } from "./controllers/profiles/history"; +import { nextup } from "./controllers/profiles/nextup"; import { watchlistH } from "./controllers/profiles/watchlist"; import { seasonsH } from "./controllers/seasons"; import { seed } from "./controllers/seed"; @@ -80,7 +81,10 @@ export const app = new Elysia({ prefix }) .use(seasonsH) .use(studiosH) .use(staffH) - .use(imagesH), + .use(imagesH) + .use(watchlistH) + .use(historyH) + .use(nextup), ) .guard( { @@ -95,6 +99,4 @@ export const app = new Elysia({ prefix }) permissions: ["core.write"], }, (app) => app.use(videosH).use(seed), - ) - .use(watchlistH) - .use(historyH); + ); diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index d37febc4..d61cf26f 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -14,7 +14,7 @@ import { alias } from "drizzle-orm/pg-core"; import Elysia, { t } from "elysia"; import { auth, getUserInfo } from "~/auth"; import { db } from "~/db"; -import { entries, history, profiles, shows, videos } from "~/db/schema"; +import { entries, history, profiles, videos } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; import { coalesce, values } from "~/db/utils"; import { Entry } from "~/models/entry"; diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts new file mode 100644 index 00000000..9b8c1dc2 --- /dev/null +++ b/api/src/controllers/profiles/nextup.ts @@ -0,0 +1,148 @@ +import { and, eq, sql } from "drizzle-orm"; +import Elysia, { t } from "elysia"; +import { auth } from "~/auth"; +import { db } from "~/db"; +import { entries, entryTranslations } from "~/db/schema"; +import { watchlist } from "~/db/schema/watchlist"; +import { getColumns, sqlarr } from "~/db/utils"; +import { Entry } from "~/models/entry"; +import { + AcceptLanguage, + Filter, + type FilterDef, + Page, + Sort, + createPage, + keysetPaginate, + processLanguages, + sortToSql, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { + entryFilters, + entryProgressQ, + entryVideosQ, + mapProgress, +} from "../entries"; + +const nextupSort = Sort( + // copy pasted from entrySort + adding new stuff + { + order: entries.order, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + number: entries.episodeNumber, + airDate: entries.airDate, + + started: watchlist.startedAt, + added: watchlist.createdAt, + updated: watchlist.updatedAt, + }, + { + default: ["updated"], + tablePk: entries.pk, + }, +); + +const nextupFilters: FilterDef = { + ...entryFilters, +}; + +export const nextup = new Elysia({ tags: ["profiles"] }) + .use(auth) + .guard({ + query: t.Object({ + sort: nextupSort, + filter: t.Optional(Filter({ def: nextupFilters })), + query: t.Optional(t.String({ description: desc.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional(t.String({ description: desc.after })), + }), + }) + .get( + "/profiles/me/nextup", + async ({ + query: { sort, filter, query, limit, after }, + headers: { "accept-language": languages }, + request: { url }, + jwt: { sub }, + }) => { + const langs = processLanguages(languages); + + const transQ = db + .selectDistinctOn([entryTranslations.pk]) + .from(entryTranslations) + .orderBy( + entryTranslations.pk, + sql`array_position(${sqlarr(langs)}, ${entryTranslations.language})`, + ) + .as("t"); + const { pk, name, ...transCol } = getColumns(transQ); + + const { + kind, + externalId, + order, + seasonNumber, + episodeNumber, + extraKind, + ...entryCol + } = getColumns(entries); + + const items = await db + .select({ + ...entryCol, + ...transCol, + videos: entryVideosQ.videos, + progress: mapProgress({ aliased: true }), + + // assign more restrained types to make typescript happy. + externalId: sql`${externalId}`, + order: sql`${order}`, + seasonNumber: sql`${seasonNumber}`, + episodeNumber: sql`${episodeNumber}`, + name: sql`${name}`, + }) + .from(entries) + .innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk)) + .innerJoin(transQ, eq(entries.pk, transQ.pk)) + .leftJoinLateral(entryVideosQ, sql`true`) + .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) + .where( + and( + filter, + query ? sql`${transQ.name} %> ${query}::text` : undefined, + keysetPaginate({ after, sort }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${transQ.name})`] + : sortToSql(sort)), + entries.pk, + ) + .limit(limit) + .execute({ userId: sub }); + + return createPage(items, { url, sort, limit }); + }, + { + detail: { + description: "", + }, + headers: t.Object( + { + "accept-language": AcceptLanguage({ autoFallback: true }), + }, + { additionalProperties: true }, + ), + response: { + 200: Page(Entry), + }, + }, + );