diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index b5546ca4..dc31f28a 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -1,10 +1,11 @@ -import { type SQL, and, eq, isNotNull, ne, sql } from "drizzle-orm"; +import { type SQL, and, desc, eq, isNotNull, ne, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { db } from "~/db"; import { entries, entryTranslations, entryVideoJoin, + history, shows, videos, } from "~/db/schema"; @@ -39,7 +40,7 @@ import { processLanguages, sortToSql, } from "~/models/utils"; -import { desc } from "~/models/utils/descriptions"; +import { desc as description } from "~/models/utils/descriptions"; import type { EmbeddedVideo } from "~/models/video"; const entryFilters: FilterDef = { @@ -149,6 +150,18 @@ async function getEntries({ .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); + const progressQ = 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)) + .orderBy(history.entryPk, desc(history.playedDate)) + .as("progress"); + const { kind, externalId, @@ -163,6 +176,9 @@ async function getEntries({ ...entryCol, ...transCol, videos: videosQ.videos, + progress: { + ...getColumns(progressQ), + }, // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, @@ -181,6 +197,7 @@ async function getEntries({ .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoinLateral(videosQ, sql`true`) + .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .where( and( filter, @@ -265,14 +282,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: t.Object({ sort: entrySort, filter: t.Optional(Filter({ def: entryFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), headers: t.Object( { @@ -342,14 +359,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: t.Object({ sort: extraSort, filter: t.Optional(Filter({ def: extraFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), response: { 200: Page(Extra), @@ -383,14 +400,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: t.Object({ sort: extraSort, filter: t.Optional(Filter({ def: unknownFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), response: { 200: Page(UnknownEntry), @@ -423,14 +440,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) detail: { description: "Get new movies/episodes added recently." }, query: t.Object({ filter: t.Optional(Filter({ def: entryFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), response: { 200: Page(Entry), diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index 63fcddde..4f7211e2 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -1,5 +1,5 @@ -import { index, integer, jsonb, timestamp } from "drizzle-orm/pg-core"; -import type { Progress } from "~/models/watchlist"; +import { sql } from "drizzle-orm"; +import { check, index, integer, jsonb, timestamp } from "drizzle-orm/pg-core"; import { entries } from "./entries"; import { profiles } from "./profiles"; import { schema } from "./utils"; @@ -18,8 +18,13 @@ export const history = schema.table( videoPk: integer() .notNull() .references(() => videos.pk, { onDelete: "set null" }), - progress: jsonb().$type(), + percent: integer().notNull().default(0), + time: integer(), playedDate: timestamp({ mode: "string" }).notNull().defaultNow(), }, - (t) => [index("history_play_date").on(t.playedDate.desc())], + (t) => [ + index("history_play_date").on(t.playedDate.desc()), + + check("percent_valid", sql`${t.percent} between 0 and 100`), + ], ); diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index 05478465..6012f126 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -10,6 +10,7 @@ import { } from "../utils"; import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { Progress } from "../watchlist"; export const BaseEpisode = t.Intersect([ t.Object({ @@ -27,7 +28,8 @@ export const Episode = t.Intersect([ EntryTranslation(), BaseEpisode, t.Object({ - videos: t.Optional(t.Array(EmbeddedVideo)), + videos: t.Array(EmbeddedVideo), + progress: Progress, }), DbMetadata, ]); diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index 1d033753..d6ce8758 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -4,6 +4,7 @@ import { madeInAbyss, registerExamples } from "../examples"; import { DbMetadata, SeedImage } from "../utils"; import { Resource } from "../utils/resource"; import { BaseEntry } from "./base-entry"; +import { Progress } from "../watchlist"; export const ExtraType = t.UnionEnum([ "other", @@ -31,7 +32,14 @@ export const BaseExtra = t.Intersect( }, ); -export const Extra = t.Intersect([Resource(), BaseExtra, DbMetadata]); +export const Extra = t.Intersect([ + Resource(), + BaseExtra, + t.Object({ + progress: t.Omit(Progress, ["videoId"]), + }), + DbMetadata, +]); export type Extra = Prettify; export const SeedExtra = t.Intersect([ diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index ceab00c4..a8215db2 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -11,6 +11,7 @@ import { } from "../utils"; import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { Progress } from "../watchlist"; export const BaseMovieEntry = t.Intersect( [ @@ -46,6 +47,7 @@ export const MovieEntry = t.Intersect([ BaseMovieEntry, t.Object({ videos: t.Optional(t.Array(EmbeddedVideo)), + progress: Progress, }), DbMetadata, ]); diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index c062f6d4..70a6191c 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -10,6 +10,7 @@ import { } from "../utils"; import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { Progress } from "../watchlist"; export const BaseSpecial = t.Intersect( [ @@ -38,6 +39,7 @@ export const Special = t.Intersect([ BaseSpecial, t.Object({ videos: t.Optional(t.Array(EmbeddedVideo)), + progress: Progress, }), DbMetadata, ]); diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts index efe1d380..22600c80 100644 --- a/api/src/models/entry/unknown-entry.ts +++ b/api/src/models/entry/unknown-entry.ts @@ -2,6 +2,7 @@ import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { bubbleImages, registerExamples, youtubeExample } from "../examples"; import { DbMetadata, Resource } from "../utils"; +import { Progress } from "../watchlist"; import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseUnknownEntry = t.Intersect( @@ -27,6 +28,9 @@ export const UnknownEntry = t.Intersect([ Resource(), UnknownEntryTranslation, BaseUnknownEntry, + t.Object({ + progress: t.Omit(Progress, ["videoId"]), + }), DbMetadata, ]); export type UnknownEntry = Prettify; diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 5cc8529a..a70924cf 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -1,10 +1,29 @@ import { t } from "elysia"; +import { comment } from "~/utils"; export const Progress = t.Object({ percent: t.Integer({ minimum: 0, maximum: 100 }), - time: t.Number({ - minimum: 0, - description: "When this episode was stopped (in seconds since the start", - }), + time: t.Nullable( + t.Integer({ + minimum: 0, + description: comment` + When this episode was stopped (in seconds since the start). + This value is null if the entry was never watched or is finished. + `, + }), + ), + videoId: t.Nullable( + t.String({ + format: "uuid", + description: comment` + Id of the video the user watched. + This can be used to resume playback in the correct video file + without asking the user what video to play. + + This will be null if the user did not watch the entry or + if the video was deleted since. + `, + }), + ), }); export type Progress = typeof Progress.static;