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
11 changed files with 249 additions and 165 deletions
+173 -111
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,122 +22,180 @@ import {
entrySort,
getEntries,
} from "../entries";
import { getOrCreateProfile } from "./profile";
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.",
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 })),
}),
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[];
},
(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)",
return createPage(items, { url, sort, limit });
},
headers: t.Object(
{
"accept-language": AcceptLanguage({ autoFallback: true }),
{
detail: {
description: "List your watch history (episodes/movies seen)",
},
{ additionalProperties: true },
),
response: {
200: Page(Entry),
},
},
)
.get(
"/profiles/:id/history",
async ({
params: { id },
query: { sort, filter, query, limit, after },
headers: { "accept-language": languages, authorization },
request: { url },
error,
}) => {
const uInfo = await getUserInfo(id, { authorization });
if ("status" in uInfo) return error(uInfo.status as 404, uInfo);
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,
headers: t.Object(
{
"accept-language": AcceptLanguage({ autoFallback: true }),
},
{ additionalProperties: true },
),
languages: langs,
userId: uInfo.id,
})) as Entry[];
return createPage(items, { url, sort, limit });
},
{
detail: {
description: "List your watch history (episodes/movies seen)",
response: {
200: Page(Entry),
},
},
params: t.Object({
id: t.String({
description:
"The id or username of the user to read the watchlist of",
example: "zoriya",
)
.get(
"/profiles/:id/history",
async ({
params: { id },
query: { sort, filter, query, limit, after },
headers: { "accept-language": languages, authorization },
request: { url },
error,
}) => {
const uInfo = await getUserInfo(id, { authorization });
if ("status" in uInfo) return error(uInfo.status as 404, uInfo);
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: uInfo.id,
})) as Entry[];
return createPage(items, { url, sort, limit });
},
{
detail: {
description: "List your watch history (episodes/movies seen)",
},
params: t.Object({
id: t.String({
description:
"The id or username of the user to read the watchlist of",
example: "zoriya",
}),
}),
}),
headers: t.Object({
authorization: t.TemplateLiteral("Bearer ${string}"),
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Entry),
403: KError,
404: {
...KError,
description: "No user found with the specified id/username.",
headers: t.Object({
authorization: t.TemplateLiteral("Bearer ${string}"),
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Entry),
403: KError,
404: {
...KError,
description: "No user found with the specified id/username.",
},
422: KError,
},
422: KError,
},
},
),
);
),
)
.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,
},
},
);
+24
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;
}
+4 -19
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({