diff --git a/api/src/base.ts b/api/src/base.ts index ab643c1f..ca88e897 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -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); diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 66a09e0c..36dfe225 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -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`${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, + }, + }, + ); diff --git a/api/src/controllers/profiles/profile.ts b/api/src/controllers/profiles/profile.ts new file mode 100644 index 00000000..7719b512 --- /dev/null +++ b/api/src/controllers/profiles/profile.ts @@ -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; +} diff --git a/api/src/controllers/profiles/watchlist.ts b/api/src/controllers/profiles/watchlist.ts index fa01f540..de642087 100644 --- a/api/src/controllers/profiles/watchlist.ts +++ b/api/src/controllers/profiles/watchlist.ts @@ -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({ diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index aea7264a..1d1460b5 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -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([ diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index 5fe5312a..fc248ff2 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -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([ diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index ab5e863c..30bdbac2 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -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( diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index d34f5c76..b687e67d 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -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( diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts index 22600c80..5ae1c811 100644 --- a/api/src/models/entry/unknown-entry.ts +++ b/api/src/models/entry/unknown-entry.ts @@ -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( diff --git a/api/src/models/history.ts b/api/src/models/history.ts new file mode 100644 index 00000000..9d76184c --- /dev/null +++ b/api/src/models/history.ts @@ -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; diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 012c2562..182b2b17 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -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",