Add route to batch populate the history

This commit is contained in:
Zoe Roux 2025-04-07 19:03:51 +02:00
parent fef9e844a1
commit e2fa3af1e8
No known key found for this signature in database
11 changed files with 249 additions and 165 deletions

View File

@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
import { auth } from "./auth"; import { auth } from "./auth";
import { entriesH } from "./controllers/entries"; import { entriesH } from "./controllers/entries";
import { imagesH } from "./controllers/images"; import { imagesH } from "./controllers/images";
import { historyH } from "./controllers/profiles/history";
import { watchlistH } from "./controllers/profiles/watchlist"; import { watchlistH } from "./controllers/profiles/watchlist";
import { seasonsH } from "./controllers/seasons"; import { seasonsH } from "./controllers/seasons";
import { seed } from "./controllers/seed"; import { seed } from "./controllers/seed";
@ -95,4 +96,5 @@ export const app = new Elysia({ prefix })
}, },
(app) => app.use(videosH).use(seed), (app) => app.use(videosH).use(seed),
) )
.use(watchlistH); .use(watchlistH)
.use(historyH);

View File

@ -1,14 +1,18 @@
import { and, isNotNull, ne } from "drizzle-orm"; import { and, eq, isNotNull, ne, not, or, sql } from "drizzle-orm";
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import { auth, getUserInfo } from "~/auth"; import { auth, getUserInfo } from "~/auth";
import { entries } from "~/db/schema"; import { db } from "~/db";
import { entries, history, videos } from "~/db/schema";
import { values } from "~/db/utils";
import { Entry } from "~/models/entry"; import { Entry } from "~/models/entry";
import { KError } from "~/models/error"; import { KError } from "~/models/error";
import { SeedHistory } from "~/models/history";
import { import {
AcceptLanguage, AcceptLanguage,
Filter, Filter,
Page, Page,
createPage, createPage,
isUuid,
processLanguages, processLanguages,
} from "~/models/utils"; } from "~/models/utils";
import { desc } from "~/models/utils/descriptions"; import { desc } from "~/models/utils/descriptions";
@ -18,8 +22,11 @@ import {
entrySort, entrySort,
getEntries, getEntries,
} from "../entries"; } from "../entries";
import { getOrCreateProfile } from "./profile";
export const historyH = new Elysia({ tags: ["profiles"] }).use(auth).guard( export const historyH = new Elysia({ tags: ["profiles"] })
.use(auth)
.guard(
{ {
query: t.Object({ query: t.Object({
sort: { sort: {
@ -136,4 +143,59 @@ export const historyH = new Elysia({ tags: ["profiles"] }).use(auth).guard(
}, },
}, },
), ),
)
.post(
"/profile/me/history",
async ({ body, jwt: { sub } }) => {
const profilePk = await getOrCreateProfile(sub);
const rows = await db
.insert(history)
.select(
db
.select({
profilePk: sql<number>`${profilePk}`,
entryPk: entries.pk,
videoPk: videos.pk,
percent: sql`hist.percent::integer`,
time: sql`hist.time::integer`,
playedDate: sql`hist.playedDate::timestamptz`,
})
.from(
values(
body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })),
).as("hist"),
)
.innerJoin(
entries,
or(
and(
sql`hist.entryUseId::boolean`,
eq(entries.id, sql`hist.entry::uuid`),
),
and(
not(sql`hist.entryUseId::boolean`),
eq(entries.id, sql`hist.entry`),
),
),
)
.innerJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)),
)
.returning({ pk: history.pk });
return { status: 201, inserted: rows.length };
},
{
detail: { description: "Bulk add entries/movies to your watch history." },
body: t.Array(SeedHistory),
permissions: ["core.read"],
response: {
201: t.Object({
status: t.Literal(201),
inserted: t.Integer({
description: "The number of history entry inserted",
}),
}),
422: KError,
},
},
); );

View File

@ -0,0 +1,24 @@
import { eq, sql } from "drizzle-orm";
import { db } from "~/db";
import { profiles } from "~/db/schema";
export async function getOrCreateProfile(userId: string) {
let [profile] = await db
.select({ pk: profiles.pk })
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
if (profile) return profile.pk;
[profile] = await db
.insert(profiles)
.values({ id: userId })
.onConflictDoUpdate({
// we can't do `onConflictDoNothing` because on race conditions
// we still want the profile to be returned.
target: [profiles.id],
set: { id: sql`excluded.id` },
})
.returning({ pk: profiles.pk });
return profile.pk;
}

View File

@ -8,7 +8,7 @@ import {
watchStatusQ, watchStatusQ,
} from "~/controllers/shows/logic"; } from "~/controllers/shows/logic";
import { db } from "~/db"; import { db } from "~/db";
import { profiles, shows } from "~/db/schema"; import { shows } from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist"; import { watchlist } from "~/db/schema/watchlist";
import { conflictUpdateAllExcept, getColumns } from "~/db/utils"; import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
import { KError } from "~/models/error"; import { KError } from "~/models/error";
@ -25,6 +25,7 @@ import {
} from "~/models/utils"; } from "~/models/utils";
import { desc } from "~/models/utils/descriptions"; import { desc } from "~/models/utils/descriptions";
import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist"; import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import { getOrCreateProfile } from "./profile";
async function setWatchStatus({ async function setWatchStatus({
show, show,
@ -35,29 +36,13 @@ async function setWatchStatus({
status: SerieWatchStatus; status: SerieWatchStatus;
userId: string; userId: string;
}) { }) {
let [profile] = await db const profilePk = await getOrCreateProfile(userId);
.select({ pk: profiles.pk })
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
if (!profile) {
[profile] = await db
.insert(profiles)
.values({ id: userId })
.onConflictDoUpdate({
// we can't do `onConflictDoNothing` because on race conditions
// we still want the profile to be returned.
target: [profiles.id],
set: { id: sql`excluded.id` },
})
.returning({ pk: profiles.pk });
}
const [ret] = await db const [ret] = await db
.insert(watchlist) .insert(watchlist)
.values({ .values({
...status, ...status,
profilePk: profile.pk, profilePk: profilePk,
showPk: show.pk, showPk: show.pk,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({

View File

@ -1,6 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import type { Prettify } from "~/utils"; import type { Prettify } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { Progress } from "../history";
import { import {
DbMetadata, DbMetadata,
EpisodeId, EpisodeId,
@ -9,7 +10,6 @@ import {
TranslationRecord, TranslationRecord,
} from "../utils"; } from "../utils";
import { EmbeddedVideo } from "../video"; import { EmbeddedVideo } from "../video";
import { Progress } from "../watchlist";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseEpisode = t.Intersect([ export const BaseEpisode = t.Intersect([

View File

@ -1,9 +1,9 @@
import { t } from "elysia"; import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { madeInAbyss, registerExamples } from "../examples"; import { madeInAbyss, registerExamples } from "../examples";
import { Progress } from "../history";
import { DbMetadata, SeedImage } from "../utils"; import { DbMetadata, SeedImage } from "../utils";
import { Resource } from "../utils/resource"; import { Resource } from "../utils/resource";
import { Progress } from "../watchlist";
import { BaseEntry } from "./base-entry"; import { BaseEntry } from "./base-entry";
export const ExtraType = t.UnionEnum([ export const ExtraType = t.UnionEnum([

View File

@ -1,6 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { Progress } from "../history";
import { import {
DbMetadata, DbMetadata,
ExternalId, ExternalId,
@ -10,7 +11,6 @@ import {
TranslationRecord, TranslationRecord,
} from "../utils"; } from "../utils";
import { EmbeddedVideo } from "../video"; import { EmbeddedVideo } from "../video";
import { Progress } from "../watchlist";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseMovieEntry = t.Intersect( export const BaseMovieEntry = t.Intersect(

View File

@ -1,6 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import { type Prettify, comment } from "~/utils"; import { type Prettify, comment } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { Progress } from "../history";
import { import {
DbMetadata, DbMetadata,
EpisodeId, EpisodeId,
@ -9,7 +10,6 @@ import {
TranslationRecord, TranslationRecord,
} from "../utils"; } from "../utils";
import { EmbeddedVideo } from "../video"; import { EmbeddedVideo } from "../video";
import { Progress } from "../watchlist";
import { BaseEntry, EntryTranslation } from "./base-entry"; import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseSpecial = t.Intersect( export const BaseSpecial = t.Intersect(

View File

@ -1,8 +1,8 @@
import { t } from "elysia"; 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 { Progress } from "../history";
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(

40
api/src/models/history.ts Normal file
View File

@ -0,0 +1,40 @@
import { t } from "elysia";
import { comment } from "~/utils";
export const Progress = t.Object({
percent: t.Integer({ minimum: 0, maximum: 100 }),
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.
`,
}),
),
playedDate: t.Nullable(t.String({ format: "date-time" })),
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 const SeedHistory = t.Intersect([
t.Object({
entry: t.String({
description: "Id or slug of the entry/movie you watched",
}),
}),
Progress,
]);
export type SeedHistory = typeof SeedHistory.static;

View File

@ -1,33 +1,4 @@
import { t } from "elysia"; import { t } from "elysia";
import { comment } from "~/utils";
export const Progress = t.Object({
percent: t.Integer({ minimum: 0, maximum: 100 }),
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.
`,
}),
),
playedDate: t.Nullable(t.String({ format: "date-time" })),
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 const WatchlistStatus = t.UnionEnum([ export const WatchlistStatus = t.UnionEnum([
"completed", "completed",