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 { entriesH } from "./controllers/entries";
import { imagesH } from "./controllers/images";
import { historyH } from "./controllers/profiles/history";
import { watchlistH } from "./controllers/profiles/watchlist";
import { seasonsH } from "./controllers/seasons";
import { seed } from "./controllers/seed";
@ -95,4 +96,5 @@ export const app = new Elysia({ prefix })
},
(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 { 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 { KError } from "~/models/error";
import { SeedHistory } from "~/models/history";
import {
AcceptLanguage,
Filter,
Page,
createPage,
isUuid,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
@ -18,8 +22,11 @@ import {
entrySort,
getEntries,
} 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({
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,
} from "~/controllers/shows/logic";
import { db } from "~/db";
import { profiles, shows } from "~/db/schema";
import { shows } from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
import { KError } from "~/models/error";
@ -25,6 +25,7 @@ import {
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import { getOrCreateProfile } from "./profile";
async function setWatchStatus({
show,
@ -35,29 +36,13 @@ async function setWatchStatus({
status: SerieWatchStatus;
userId: string;
}) {
let [profile] = await db
.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 profilePk = await getOrCreateProfile(userId);
const [ret] = await db
.insert(watchlist)
.values({
...status,
profilePk: profile.pk,
profilePk: profilePk,
showPk: show.pk,
})
.onConflictDoUpdate({

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { t } from "elysia";
import { type Prettify, comment } from "~/utils";
import { bubbleImages, registerExamples, youtubeExample } from "../examples";
import { Progress } from "../history";
import { DbMetadata, Resource } from "../utils";
import { Progress } from "../watchlist";
import { BaseEntry, EntryTranslation } from "./base-entry";
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 { 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([
"completed",