mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add route to batch populate the history
This commit is contained in:
parent
fef9e844a1
commit
e2fa3af1e8
@ -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);
|
||||
|
@ -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
api/src/controllers/profiles/profile.ts
Normal file
24
api/src/controllers/profiles/profile.ts
Normal 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;
|
||||
}
|
@ -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({
|
||||
|
@ -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([
|
||||
|
@ -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([
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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
40
api/src/models/history.ts
Normal 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;
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user