diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index ac0bd5d0..42294fd0 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -45,7 +45,22 @@ import { import { desc as description } from "~/models/utils/descriptions"; import type { EmbeddedVideo } from "~/models/video"; -const entryFilters: FilterDef = { +export const entryProgressQ = db + .selectDistinctOn([history.entryPk], { + percent: history.percent, + time: history.time, + entryPk: history.entryPk, + playedDate: history.playedDate, + videoId: videos.id, + }) + .from(history) + .leftJoin(videos, eq(history.videoPk, videos.pk)) + .leftJoin(profiles, eq(history.profilePk, profiles.pk)) + .where(eq(profiles.id, sql.placeholder("userId"))) + .orderBy(history.entryPk, desc(history.playedDate)) + .as("progress"); + +export const entryFilters: FilterDef = { kind: { column: entries.kind, type: "enum", @@ -57,18 +72,21 @@ const entryFilters: FilterDef = { order: { column: entries.order, type: "float" }, runtime: { column: entries.runtime, type: "float" }, airDate: { column: entries.airDate, type: "date" }, + playedDate: { column: entryProgressQ.playedDate, type: "date" }, }; const extraFilters: FilterDef = { kind: { column: entries.extraKind, type: "enum", values: ExtraType.enum }, runtime: { column: entries.runtime, type: "float" }, + playedDate: { column: entryProgressQ.playedDate, type: "date" }, }; const unknownFilters: FilterDef = { runtime: { column: entries.runtime, type: "float" }, + playedDate: { column: entryProgressQ.playedDate, type: "date" }, }; -const entrySort = Sort( +export const entrySort = Sort( { order: entries.order, seasonNumber: entries.seasonNumber, @@ -76,6 +94,7 @@ const entrySort = Sort( number: entries.episodeNumber, airDate: entries.airDate, nextRefresh: entries.nextRefresh, + playedDate: entryProgressQ.playedDate, }, { default: ["order"], @@ -89,6 +108,7 @@ const extraSort = Sort( name: entryTranslations.name, runtime: entries.runtime, createdAt: entries.createdAt, + playedDate: entryProgressQ.playedDate, }, { default: ["slug"], @@ -126,36 +146,19 @@ export const entryVideosQ = db .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); -export const getEntryProgressQ = (userId: string) => - db - .selectDistinctOn([history.entryPk], { - percent: history.percent, - time: history.time, - entryPk: history.entryPk, - videoId: videos.id, - }) - .from(history) - .leftJoin(videos, eq(history.videoPk, videos.pk)) - .leftJoin(profiles, eq(history.profilePk, profiles.pk)) - .where(eq(profiles.id, userId)) - .orderBy(history.entryPk, desc(history.playedDate)) - .as("progress"); - -export const mapProgress = ( - progressQ: ReturnType, - { aliased }: { aliased: boolean } = { aliased: false }, -) => { - const { time, percent, videoId } = getColumns(progressQ); +export const mapProgress = ({ aliased }: { aliased: boolean }) => { + const { time, percent, playedDate, videoId } = getColumns(entryProgressQ); const ret = { time: coalesce(time, sql`0`), percent: coalesce(percent, sql`0`), + playedDate: sql`${playedDate}`, videoId: sql`${videoId}`, }; if (!aliased) return ret; return Object.fromEntries(Object.entries(ret).map(([k, v]) => [k, v.as(k)])); }; -async function getEntries({ +export async function getEntries({ after, limit, query, @@ -182,8 +185,6 @@ async function getEntries({ .as("t"); const { pk, name, ...transCol } = getColumns(transQ); - const entryProgressQ = getEntryProgressQ(userId); - const { kind, externalId, @@ -198,7 +199,7 @@ async function getEntries({ ...entryCol, ...transCol, videos: entryVideosQ.videos, - progress: mapProgress(entryProgressQ, { aliased: true }), + progress: mapProgress({ aliased: true }), // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, @@ -231,7 +232,8 @@ async function getEntries({ : sortToSql(sort)), entries.pk, ) - .limit(limit); + .limit(limit) + .execute({ userId }); } export const entriesH = new Elysia({ tags: ["series"] }) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts new file mode 100644 index 00000000..574fcac6 --- /dev/null +++ b/api/src/controllers/profiles/history.ts @@ -0,0 +1,81 @@ +import { and, eq, isNotNull, ne } from "drizzle-orm"; +import Elysia, { t } from "elysia"; +import { auth } from "~/auth"; +import { entries } from "~/db/schema"; +import { Entry } from "~/models/entry"; +import { + AcceptLanguage, + Filter, + Page, + createPage, + processLanguages, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { + entryFilters, + entryProgressQ, + entrySort, + getEntries, +} from "../entries"; + +export const historyH = new Elysia({ tags: ["profiles"] }).use(auth).guard( + { + query: t.Object({ + sort: { + ...entrySort, + default: ["-playedDate"], + }, + filter: t.Optional(Filter({ def: entryFilters })), + 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 })), + }), + }, + (app) => + app.get( + "/profiles/me/history", + async ({ + query: { sort, filter, query, limit, after }, + headers: { "accept-language": languages }, + request: { url }, + jwt: { sub }, + }) => { + const langs = processLanguages(languages); + const items = (await getEntries({ + limit, + after, + query, + sort, + filter: and( + isNotNull(entryProgressQ.playedDate), + ne(entries.kind, "extra"), + ne(entries.kind, "unknown"), + filter, + ), + languages: langs, + userId: sub, + })) as Entry[]; + + return createPage(items, { url, sort, limit }); + }, + { + detail: { + description: "List your watch history (episodes/movies seen)", + }, + headers: t.Object( + { + "accept-language": AcceptLanguage({ autoFallback: true }), + }, + { additionalProperties: true }, + ), + response: { + 200: Page(Entry), + }, + }, + ), +); diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 96e883e4..40b72b5f 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -36,7 +36,7 @@ import { } from "~/models/utils"; import type { EmbeddedVideo } from "~/models/video"; import { WatchlistStatus } from "~/models/watchlist"; -import { entryVideosQ, getEntryProgressQ, mapProgress } from "../entries"; +import { entryProgressQ, entryVideosQ, mapProgress } from "../entries"; export const watchStatusQ = db .select({ @@ -75,6 +75,7 @@ export const showFilters: FilterDef = { type: "enum", values: WatchlistStatus.enum, }, + score: { column: watchStatusQ.score, type: "int" }, }; export const showSort = Sort( { @@ -86,6 +87,7 @@ export const showSort = Sort( createdAt: shows.createdAt, nextRefresh: shows.nextRefresh, watchStatus: watchStatusQ.status, + score: watchStatusQ.score, }, { default: ["slug"], @@ -164,10 +166,7 @@ const showRelations = { .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); }, - firstEntry: ({ - languages, - userId, - }: { languages: string[]; userId: string }) => { + firstEntry: ({ languages }: { languages: string[] }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) @@ -178,8 +177,6 @@ const showRelations = { .as("t"); const { pk, ...transCol } = getColumns(transQ); - const progressQ = getEntryProgressQ(userId); - return db .select({ firstEntry: jsonbBuildObject({ @@ -187,12 +184,12 @@ const showRelations = { ...transCol, number: entries.episodeNumber, videos: entryVideosQ.videos, - progress: mapProgress(progressQ), + progress: mapProgress({ aliased: false }), }).as("firstEntry"), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) + .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .leftJoinLateral(entryVideosQ, sql`true`) .where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra"))) .orderBy(entries.order) @@ -201,10 +198,8 @@ const showRelations = { }, nextEntry: ({ languages, - userId, }: { languages: string[]; - userId: string; }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) @@ -216,8 +211,6 @@ const showRelations = { .as("t"); const { pk, ...transCol } = getColumns(transQ); - const progressQ = getEntryProgressQ(userId); - return db .select({ nextEntry: jsonbBuildObject({ @@ -225,12 +218,12 @@ const showRelations = { ...transCol, number: entries.episodeNumber, videos: entryVideosQ.videos, - progress: mapProgress(progressQ), + progress: mapProgress({ aliased: false }), }).as("nextEntry"), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) + .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .leftJoinLateral(entryVideosQ, sql`true`) .where(eq(watchStatusQ.nextEntry, entries.pk)) .as("nextEntry"); @@ -294,7 +287,7 @@ export async function getShows({ watchStatus: getColumns(watchStatusQ), - ...buildRelations(relations, showRelations, { languages, userId }), + ...buildRelations(relations, showRelations, { languages }), }) .from(shows) .leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk)) diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index b341ea3e..012c2562 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -12,6 +12,7 @@ export const Progress = t.Object({ `, }), ), + playedDate: t.Nullable(t.String({ format: "date-time" })), videoId: t.Nullable( t.String({ format: "uuid",