Add progress status in every entry

This commit is contained in:
Zoe Roux 2025-03-11 15:20:33 +01:00
parent 781a6a8196
commit 6ecaec2dee
No known key found for this signature in database
8 changed files with 79 additions and 20 deletions

View File

@ -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 { Elysia, t } from "elysia";
import { db } from "~/db"; import { db } from "~/db";
import { import {
entries, entries,
entryTranslations, entryTranslations,
entryVideoJoin, entryVideoJoin,
history,
shows, shows,
videos, videos,
} from "~/db/schema"; } from "~/db/schema";
@ -39,7 +40,7 @@ import {
processLanguages, processLanguages,
sortToSql, sortToSql,
} from "~/models/utils"; } from "~/models/utils";
import { desc } from "~/models/utils/descriptions"; import { desc as description } from "~/models/utils/descriptions";
import type { EmbeddedVideo } from "~/models/video"; import type { EmbeddedVideo } from "~/models/video";
const entryFilters: FilterDef = { const entryFilters: FilterDef = {
@ -149,6 +150,18 @@ async function getEntries({
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
.as("videos"); .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 { const {
kind, kind,
externalId, externalId,
@ -163,6 +176,9 @@ async function getEntries({
...entryCol, ...entryCol,
...transCol, ...transCol,
videos: videosQ.videos, videos: videosQ.videos,
progress: {
...getColumns(progressQ),
},
// specials don't have an `episodeNumber` but a `number` field. // specials don't have an `episodeNumber` but a `number` field.
number: episodeNumber, number: episodeNumber,
@ -181,6 +197,7 @@ async function getEntries({
.from(entries) .from(entries)
.innerJoin(transQ, eq(entries.pk, transQ.pk)) .innerJoin(transQ, eq(entries.pk, transQ.pk))
.leftJoinLateral(videosQ, sql`true`) .leftJoinLateral(videosQ, sql`true`)
.leftJoin(progressQ, eq(entries.pk, progressQ.entryPk))
.where( .where(
and( and(
filter, filter,
@ -265,14 +282,14 @@ export const entriesH = new Elysia({ tags: ["series"] })
query: t.Object({ query: t.Object({
sort: entrySort, sort: entrySort,
filter: t.Optional(Filter({ def: entryFilters })), 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({ limit: t.Integer({
minimum: 1, minimum: 1,
maximum: 250, maximum: 250,
default: 50, default: 50,
description: "Max page size.", description: "Max page size.",
}), }),
after: t.Optional(t.String({ description: desc.after })), after: t.Optional(t.String({ description: description.after })),
}), }),
headers: t.Object( headers: t.Object(
{ {
@ -342,14 +359,14 @@ export const entriesH = new Elysia({ tags: ["series"] })
query: t.Object({ query: t.Object({
sort: extraSort, sort: extraSort,
filter: t.Optional(Filter({ def: extraFilters })), 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({ limit: t.Integer({
minimum: 1, minimum: 1,
maximum: 250, maximum: 250,
default: 50, default: 50,
description: "Max page size.", description: "Max page size.",
}), }),
after: t.Optional(t.String({ description: desc.after })), after: t.Optional(t.String({ description: description.after })),
}), }),
response: { response: {
200: Page(Extra), 200: Page(Extra),
@ -383,14 +400,14 @@ export const entriesH = new Elysia({ tags: ["series"] })
query: t.Object({ query: t.Object({
sort: extraSort, sort: extraSort,
filter: t.Optional(Filter({ def: unknownFilters })), 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({ limit: t.Integer({
minimum: 1, minimum: 1,
maximum: 250, maximum: 250,
default: 50, default: 50,
description: "Max page size.", description: "Max page size.",
}), }),
after: t.Optional(t.String({ description: desc.after })), after: t.Optional(t.String({ description: description.after })),
}), }),
response: { response: {
200: Page(UnknownEntry), 200: Page(UnknownEntry),
@ -423,14 +440,14 @@ export const entriesH = new Elysia({ tags: ["series"] })
detail: { description: "Get new movies/episodes added recently." }, detail: { description: "Get new movies/episodes added recently." },
query: t.Object({ query: t.Object({
filter: t.Optional(Filter({ def: entryFilters })), 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({ limit: t.Integer({
minimum: 1, minimum: 1,
maximum: 250, maximum: 250,
default: 50, default: 50,
description: "Max page size.", description: "Max page size.",
}), }),
after: t.Optional(t.String({ description: desc.after })), after: t.Optional(t.String({ description: description.after })),
}), }),
response: { response: {
200: Page(Entry), 200: Page(Entry),

View File

@ -1,5 +1,5 @@
import { index, integer, jsonb, timestamp } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm";
import type { Progress } from "~/models/watchlist"; import { check, index, integer, jsonb, timestamp } from "drizzle-orm/pg-core";
import { entries } from "./entries"; import { entries } from "./entries";
import { profiles } from "./profiles"; import { profiles } from "./profiles";
import { schema } from "./utils"; import { schema } from "./utils";
@ -18,8 +18,13 @@ export const history = schema.table(
videoPk: integer() videoPk: integer()
.notNull() .notNull()
.references(() => videos.pk, { onDelete: "set null" }), .references(() => videos.pk, { onDelete: "set null" }),
progress: jsonb().$type<Progress>(), percent: integer().notNull().default(0),
time: integer(),
playedDate: timestamp({ mode: "string" }).notNull().defaultNow(), 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`),
],
); );

View File

@ -10,6 +10,7 @@ import {
} from "../utils"; } from "../utils";
import { EmbeddedVideo } from "../video"; import { EmbeddedVideo } from "../video";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
import { Progress } from "../watchlist";
export const BaseEpisode = t.Intersect([ export const BaseEpisode = t.Intersect([
t.Object({ t.Object({
@ -27,7 +28,8 @@ export const Episode = t.Intersect([
EntryTranslation(), EntryTranslation(),
BaseEpisode, BaseEpisode,
t.Object({ t.Object({
videos: t.Optional(t.Array(EmbeddedVideo)), videos: t.Array(EmbeddedVideo),
progress: Progress,
}), }),
DbMetadata, DbMetadata,
]); ]);

View File

@ -4,6 +4,7 @@ import { madeInAbyss, registerExamples } from "../examples";
import { DbMetadata, SeedImage } from "../utils"; import { DbMetadata, SeedImage } from "../utils";
import { Resource } from "../utils/resource"; import { Resource } from "../utils/resource";
import { BaseEntry } from "./base-entry"; import { BaseEntry } from "./base-entry";
import { Progress } from "../watchlist";
export const ExtraType = t.UnionEnum([ export const ExtraType = t.UnionEnum([
"other", "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<typeof Extra.static>; export type Extra = Prettify<typeof Extra.static>;
export const SeedExtra = t.Intersect([ export const SeedExtra = t.Intersect([

View File

@ -11,6 +11,7 @@ import {
} from "../utils"; } from "../utils";
import { EmbeddedVideo } from "../video"; import { EmbeddedVideo } from "../video";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
import { Progress } from "../watchlist";
export const BaseMovieEntry = t.Intersect( export const BaseMovieEntry = t.Intersect(
[ [
@ -46,6 +47,7 @@ export const MovieEntry = t.Intersect([
BaseMovieEntry, BaseMovieEntry,
t.Object({ t.Object({
videos: t.Optional(t.Array(EmbeddedVideo)), videos: t.Optional(t.Array(EmbeddedVideo)),
progress: Progress,
}), }),
DbMetadata, DbMetadata,
]); ]);

View File

@ -10,6 +10,7 @@ import {
} from "../utils"; } from "../utils";
import { EmbeddedVideo } from "../video"; import { EmbeddedVideo } from "../video";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
import { Progress } from "../watchlist";
export const BaseSpecial = t.Intersect( export const BaseSpecial = t.Intersect(
[ [
@ -38,6 +39,7 @@ export const Special = t.Intersect([
BaseSpecial, BaseSpecial,
t.Object({ t.Object({
videos: t.Optional(t.Array(EmbeddedVideo)), videos: t.Optional(t.Array(EmbeddedVideo)),
progress: Progress,
}), }),
DbMetadata, DbMetadata,
]); ]);

View File

@ -2,6 +2,7 @@ import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { bubbleImages, registerExamples, youtubeExample } from "../examples"; import { bubbleImages, registerExamples, youtubeExample } from "../examples";
import { DbMetadata, Resource } from "../utils"; import { DbMetadata, Resource } from "../utils";
import { Progress } from "../watchlist";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseUnknownEntry = t.Intersect( export const BaseUnknownEntry = t.Intersect(
@ -27,6 +28,9 @@ export const UnknownEntry = t.Intersect([
Resource(), Resource(),
UnknownEntryTranslation, UnknownEntryTranslation,
BaseUnknownEntry, BaseUnknownEntry,
t.Object({
progress: t.Omit(Progress, ["videoId"]),
}),
DbMetadata, DbMetadata,
]); ]);
export type UnknownEntry = Prettify<typeof UnknownEntry.static>; export type UnknownEntry = Prettify<typeof UnknownEntry.static>;

View File

@ -1,10 +1,29 @@
import { t } from "elysia"; import { t } from "elysia";
import { comment } from "~/utils";
export const Progress = t.Object({ export const Progress = t.Object({
percent: t.Integer({ minimum: 0, maximum: 100 }), percent: t.Integer({ minimum: 0, maximum: 100 }),
time: t.Number({ time: t.Nullable(
minimum: 0, t.Integer({
description: "When this episode was stopped (in seconds since the start", 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; export type Progress = typeof Progress.static;