Add nextup/continue watching routes (#883)

This commit is contained in:
Zoe Roux 2025-04-09 00:01:52 +02:00 committed by GitHub
commit 60764f6c06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2324 additions and 65 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "kyoo"."watchlist" ADD COLUMN "last_played_at" timestamp with time zone;

File diff suppressed because it is too large Load Diff

View File

@ -134,6 +134,13 @@
"when": 1744053556621,
"tag": "0018_history",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1744120518941,
"tag": "0019_nextup",
"breakpoints": true
}
]
}

View File

@ -3,6 +3,7 @@ import { auth } from "./auth";
import { entriesH } from "./controllers/entries";
import { imagesH } from "./controllers/images";
import { historyH } from "./controllers/profiles/history";
import { nextup } from "./controllers/profiles/nextup";
import { watchlistH } from "./controllers/profiles/watchlist";
import { seasonsH } from "./controllers/seasons";
import { seed } from "./controllers/seed";
@ -80,7 +81,10 @@ export const app = new Elysia({ prefix })
.use(seasonsH)
.use(studiosH)
.use(staffH)
.use(imagesH),
.use(imagesH)
.use(watchlistH)
.use(historyH)
.use(nextup),
)
.guard(
{
@ -95,6 +99,4 @@ export const app = new Elysia({ prefix })
permissions: ["core.write"],
},
(app) => app.use(videosH).use(seed),
)
.use(watchlistH)
.use(historyH);
);

View File

@ -1,15 +1,4 @@
import {
and,
count,
eq,
exists,
gt,
isNotNull,
ne,
not,
or,
sql,
} from "drizzle-orm";
import { and, count, eq, exists, gt, isNotNull, ne, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import Elysia, { t } from "elysia";
import { auth, getUserInfo } from "~/auth";
@ -181,6 +170,12 @@ export const historyH = new Elysia({ tags: ["profiles"] })
const vals = values(
body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })),
).as("hist");
const valEqEntries = sql`
case
when hist.entryUseId::boolean then ${entries.id} = hist.entry::uuid
else ${entries.slug} = hist.entry
end
`;
const rows = await db
.insert(history)
@ -195,19 +190,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
playedDate: sql`hist.playedDate::timestamptz`,
})
.from(vals)
.innerJoin(
entries,
or(
and(
sql`hist.entryUseId::boolean`,
eq(entries.id, sql`hist.entry::uuid`),
),
and(
not(sql`hist.entryUseId::boolean`),
eq(entries.slug, sql`hist.entry`),
),
),
)
.innerJoin(entries, valEqEntries)
.leftJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)),
)
.returning({ pk: history.pk });
@ -223,10 +206,11 @@ export const historyH = new Elysia({ tags: ["profiles"] })
.where(
and(
eq(nextEntry.showPk, entries.showPk),
ne(nextEntry.kind, "extra"),
gt(nextEntry.order, entries.order),
),
)
.orderBy(nextEntry.showPk, entries.order)
.orderBy(nextEntry.order)
.limit(1)
.as("nextEntryQ");
@ -250,6 +234,11 @@ export const historyH = new Elysia({ tags: ["profiles"] })
),
);
const showKindQ = db
.select({ k: shows.kind })
.from(shows)
.where(eq(shows.pk, sql`excluded.show_pk`));
await db
.insert(watchlist)
.select(
@ -273,9 +262,15 @@ export const historyH = new Elysia({ tags: ["profiles"] })
else 0
end
`,
nextEntry: nextEntryQ.pk,
nextEntry: sql`
case
when hist.percent::integer >= 95 then ${nextEntryQ.pk}
else ${entries.pk}
end
`,
score: sql`null`,
startedAt: sql`hist.playedDate::timestamptz`,
lastPlayedAt: sql`hist.playedDate::timestamptz`,
completedAt: sql`
case
when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz
@ -286,19 +281,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
updatedAt: sql`now()`,
})
.from(vals)
.leftJoin(
entries,
or(
and(
sql`hist.entryUseId::boolean`,
eq(entries.id, sql`hist.entry::uuid`),
),
and(
not(sql`hist.entryUseId::boolean`),
eq(entries.slug, sql`hist.entry`),
),
),
)
.leftJoin(entries, valEqEntries)
.leftJoinLateral(nextEntryQ, sql`true`),
)
.onConflictDoUpdate({
@ -314,13 +297,18 @@ export const historyH = new Elysia({ tags: ["profiles"] })
else ${watchlist.status}
end
`,
seenCount: sql`${seenCountQ}`,
seenCount: sql`
case
when ${showKindQ} = 'movie' then excluded.seen_count
else ${seenCountQ}
end`,
nextEntry: sql`
case
when ${watchlist.status} = 'completed' then null
else excluded.next_entry
end
`,
lastPlayedAt: sql`excluded.last_played_at`,
completedAt: coalesce(
watchlist.completedAt,
sql`excluded.completed_at`,

View File

@ -0,0 +1,149 @@
import { and, eq, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { db } from "~/db";
import { entries, entryTranslations } from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import { getColumns, sqlarr } from "~/db/utils";
import { Entry } from "~/models/entry";
import {
AcceptLanguage,
Filter,
type FilterDef,
Page,
Sort,
createPage,
keysetPaginate,
processLanguages,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
import {
entryFilters,
entryProgressQ,
entryVideosQ,
mapProgress,
} from "../entries";
const nextupSort = Sort(
// copy pasted from entrySort + adding new stuff
{
order: entries.order,
seasonNumber: entries.seasonNumber,
episodeNumber: entries.episodeNumber,
number: entries.episodeNumber,
airDate: entries.airDate,
started: watchlist.startedAt,
added: watchlist.createdAt,
lastPlayed: watchlist.lastPlayedAt,
},
{
default: ["-lastPlayed"],
tablePk: entries.pk,
},
);
const nextupFilters: FilterDef = {
...entryFilters,
};
export const nextup = new Elysia({ tags: ["profiles"] })
.use(auth)
.guard({
query: t.Object({
sort: nextupSort,
filter: t.Optional(Filter({ def: nextupFilters })),
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 })),
}),
})
.get(
"/profiles/me/nextup",
async ({
query: { sort, filter, query, limit, after },
headers: { "accept-language": languages },
request: { url },
jwt: { sub },
}) => {
const langs = processLanguages(languages);
const transQ = db
.selectDistinctOn([entryTranslations.pk])
.from(entryTranslations)
.orderBy(
entryTranslations.pk,
sql`array_position(${sqlarr(langs)}, ${entryTranslations.language})`,
)
.as("t");
const { pk, name, ...transCol } = getColumns(transQ);
const {
externalId,
order,
seasonNumber,
episodeNumber,
extraKind,
...entryCol
} = getColumns(entries);
const items = await db
.select({
...entryCol,
...transCol,
videos: entryVideosQ.videos,
progress: mapProgress({ aliased: true }),
// specials don't have an `episodeNumber` but a `number` field.
number: episodeNumber,
// assign more restrained types to make typescript happy.
externalId: sql<any>`${externalId}`,
order: sql<number>`${order}`,
seasonNumber: sql<number>`${seasonNumber}`,
episodeNumber: sql<number>`${episodeNumber}`,
name: sql<string>`${name}`,
})
.from(entries)
.innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk))
.innerJoin(transQ, eq(entries.pk, transQ.pk))
.leftJoinLateral(entryVideosQ, sql`true`)
.leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk))
.where(
and(
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),
),
)
.orderBy(
...(query
? [sql`word_similarity(${query}::text, ${transQ.name})`]
: sortToSql(sort)),
entries.pk,
)
.limit(limit)
.execute({ userId: sub });
return createPage(items, { url, sort, limit });
},
{
detail: {
description: "",
},
headers: t.Object(
{
"accept-language": AcceptLanguage({ autoFallback: true }),
},
{ additionalProperties: true },
),
response: {
200: Page(Entry),
},
},
);

View File

@ -8,12 +8,14 @@ import {
watchStatusQ,
} from "~/controllers/shows/logic";
import { db } from "~/db";
import { shows } from "~/db/schema";
import { entries, shows } from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
import { Entry } from "~/models/entry";
import { KError } from "~/models/error";
import { bubble, madeInAbyss } from "~/models/examples";
import { Show } from "~/models/show";
import { Movie } from "~/models/movie";
import { Serie } from "~/models/serie";
import {
AcceptLanguage,
DbMetadata,
@ -32,18 +34,38 @@ async function setWatchStatus({
status,
userId,
}: {
show: { pk: number; kind: "movie" | "serie" };
status: SerieWatchStatus;
show:
| { pk: number; kind: "movie" }
| { pk: number; kind: "serie"; entriesCount: number };
status: Omit<SerieWatchStatus, "seenCount">;
userId: string;
}) {
const profilePk = await getOrCreateProfile(userId);
const firstEntryQ = db
.select({ pk: entries.pk })
.from(entries)
.where(eq(entries.showPk, show.pk))
.orderBy(entries.order)
.limit(1);
const [ret] = await db
.insert(watchlist)
.values({
...status,
profilePk: profilePk,
seenCount:
status.status === "completed"
? show.kind === "movie"
? 100
: show.entriesCount
: 0,
showPk: show.pk,
nextEntry:
status.status === "watching" || status.status === "rewatching"
? sql`${firstEntryQ}`
: sql`null`,
lastPlayedAt: status.startedAt,
})
.onConflictDoUpdate({
target: [watchlist.profilePk, watchlist.showPk],
@ -53,10 +75,32 @@ async function setWatchStatus({
"showPk",
"createdAt",
"seenCount",
"nextEntry",
"lastPlayedAt",
]),
// do not reset movie's progress during drop
...(show.kind === "movie" && status.status !== "dropped"
? { seenCount: sql`excluded.seen_count` }
...(status.status === "completed"
? {
seenCount: sql`excluded.seen_count`,
nextEntry: sql`null`,
}
: {}),
// only set seenCount & nextEntry when marking as "rewatching"
// if it's already rewatching, the history updates are more up-dated.
...(status.status === "rewatching"
? {
seenCount: sql`
case when ${watchlist.status} != 'rewatching'
then excluded.seen_count
else
${watchlist.seenCount}
end`,
nextEntry: sql`
case when ${watchlist.status} != 'rewatching'
then excluded.next_entry
else
${watchlist.nextEntry}
end`,
}
: {}),
},
})
@ -115,6 +159,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
),
languages: langs,
preferOriginal: preferOriginal ?? settings.preferOriginal,
relations: ["nextEntry"],
userId: sub,
});
return createPage(items, { url, sort, limit });
@ -128,7 +173,18 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
{ additionalProperties: true },
),
response: {
200: Page(Show),
200: Page(
t.Union([
t.Intersect([Movie, t.Object({ kind: t.Literal("movie") })]),
t.Intersect([
Serie,
t.Object({
kind: t.Literal("serie"),
nextEntry: t.Optional(t.Nullable(Entry)),
}),
]),
]),
),
422: KError,
},
},
@ -159,6 +215,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
),
languages: langs,
preferOriginal: preferOriginal ?? settings.preferOriginal,
relations: ["nextEntry"],
userId: uInfo.id,
});
return createPage(items, { url, sort, limit });
@ -179,7 +236,18 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Show),
200: Page(
t.Union([
t.Intersect([Movie, t.Object({ kind: t.Literal("movie") })]),
t.Intersect([
Serie,
t.Object({
kind: t.Literal("serie"),
nextEntry: t.Optional(t.Nullable(Entry)),
}),
]),
]),
),
403: KError,
404: {
...KError,
@ -195,7 +263,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
"/series/:id/watchstatus",
async ({ params: { id }, body, jwt: { sub }, error }) => {
const [show] = await db
.select({ pk: shows.pk })
.select({ pk: shows.pk, entriesCount: shows.entriesCount })
.from(shows)
.where(
and(
@ -211,7 +279,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
});
}
return await setWatchStatus({
show: { pk: show.pk, kind: "serie" },
show: { pk: show.pk, kind: "serie", entriesCount: show.entriesCount },
userId: sub,
status: body,
});
@ -224,7 +292,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
example: madeInAbyss.slug,
}),
}),
body: SerieWatchStatus,
body: t.Omit(SerieWatchStatus, ["seenCount"]),
response: {
200: t.Intersect([SerieWatchStatus, DbMetadata]),
404: KError,
@ -258,8 +326,6 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
status: {
...body,
startedAt: body.completedAt,
// for movies, watch-percent is stored in `seenCount`.
seenCount: body.status === "completed" ? 100 : 0,
},
});
},

View File

@ -196,11 +196,7 @@ const showRelations = {
.limit(1)
.as("firstEntry");
},
nextEntry: ({
languages,
}: {
languages: string[];
}) => {
nextEntry: ({ languages }: { languages: string[] }) => {
const transQ = db
.selectDistinctOn([entryTranslations.pk])
.from(entryTranslations)

View File

@ -30,6 +30,7 @@ export const watchlist = schema.table(
score: integer(),
startedAt: timestamp({ withTimezone: true, mode: "string" }),
lastPlayedAt: timestamp({ withTimezone: true, mode: "string" }),
completedAt: timestamp({ withTimezone: true, mode: "string" }),
createdAt: timestamp({ withTimezone: true, mode: "string" })

View File

@ -82,6 +82,7 @@ export const FullSerie = t.Intersect([
translations: t.Optional(TranslationRecord(SerieTranslation)),
studios: t.Optional(t.Array(Studio)),
firstEntry: t.Optional(Entry),
nextEntry: t.Optional(t.Nullable(Entry)),
}),
]);
export type FullSerie = Prettify<typeof FullSerie.static>;

View File

@ -184,7 +184,10 @@ export const getNews = async ({
return [resp, body] as const;
};
export const setSerieStatus = async (id: string, status: SerieWatchStatus) => {
export const setSerieStatus = async (
id: string,
status: Omit<SerieWatchStatus, "seenCount">,
) => {
const resp = await app.handle(
new Request(buildUrl(`series/${id}/watchstatus`), {
method: "POST",

View File

@ -58,3 +58,33 @@ export const getWatchlist = async (
const body = await resp.json();
return [resp, body] as const;
};
export const getNextup = async (
id: string,
{
langs,
...query
}: {
filter?: string;
limit?: number;
after?: string;
sort?: string | string[];
query?: string;
langs?: string;
preferOriginal?: boolean;
},
) => {
const resp = await app.handle(
new Request(buildUrl(`profiles/${id}/nextup`, query), {
method: "GET",
headers: langs
? {
"Accept-Language": langs,
...(await getJwtHeaders()),
}
: await getJwtHeaders(),
}),
);
const body = await resp.json();
return [resp, body] as const;
};

View File

@ -0,0 +1,170 @@
import { beforeAll, describe, expect, it } from "bun:test";
import {
addToHistory,
createMovie,
createSerie,
getMovie,
getNextup,
getSerie,
getWatchlist,
setMovieStatus,
setSerieStatus,
} from "tests/helpers";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { entries, shows, videos } from "~/db/schema";
import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples";
beforeAll(async () => {
await db.delete(shows);
await db.delete(entries);
await db.delete(videos);
// create video beforehand to test linking
await db.insert(videos).values(madeInAbyssVideo);
let [ret, body] = await createSerie(madeInAbyss);
expectStatus(ret, body).toBe(201);
[ret, body] = await createMovie(bubble);
expectStatus(ret, body).toBe(201);
});
const miaEntrySlug = `${madeInAbyss.slug}-s1e13`;
const miaNextEntrySlug = `${madeInAbyss.slug}-sp3`;
describe("nextup", () => {
it("Watchlist populates nextup", async () => {
let [r, b] = await setMovieStatus(bubble.slug, {
status: "watching",
completedAt: null,
score: null,
});
expectStatus(r, b).toBe(200);
[r, b] = await setSerieStatus(madeInAbyss.slug, {
status: "watching",
startedAt: "2024-12-22",
completedAt: null,
score: null,
});
expectStatus(r, b).toBe(200);
// only edit score, shouldn't change order
[r, b] = await setMovieStatus(bubble.slug, {
status: "watching",
completedAt: null,
score: 90,
});
expectStatus(r, b).toBe(200);
[r, b] = await getWatchlist("me", {});
expectStatus(r, b).toBe(200);
expect(b.items).toBeArrayOfSize(2);
const [resp, body] = await getNextup("me", {});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(2);
expect(body.items[0].slug).toBe(miaEntrySlug);
expect(body.items[0].progress).toMatchObject({
percent: 0,
});
expect(body.items[1].slug).toBe(bubble.slug);
expect(body.items[1].progress).toMatchObject({
percent: 0,
});
});
it("/series/:id?with=nextEntry", async () => {
const [resp, body] = await getSerie(madeInAbyss.slug, {
with: ["nextEntry"],
});
expectStatus(resp, body).toBe(200);
expect(body.nextEntry).toBeObject();
expect(body.nextEntry.slug).toBe(miaEntrySlug);
expect(body.nextEntry.progress).toMatchObject({
percent: 0,
});
});
it("history watching doesn't update", async () => {
let [resp, body] = await addToHistory("me", [
{
entry: miaEntrySlug,
videoId: madeInAbyssVideo.id,
percent: 58,
time: 28 * 60 + 12,
playedDate: "2025-02-01",
},
{
entry: bubble.slug,
videoId: null,
percent: 100,
time: 2 * 60,
playedDate: "2025-02-02",
},
]);
expectStatus(resp, body).toBe(201);
expect(body.inserted).toBe(2);
[resp, body] = await getSerie(madeInAbyss.slug, {
with: ["nextEntry"],
});
expectStatus(resp, body).toBe(200);
expect(body.nextEntry).toBeObject();
expect(body.nextEntry.slug).toBe(miaEntrySlug);
expect(body.nextEntry.progress).toMatchObject({
percent: 58,
time: 28 * 60 + 12,
videoId: madeInAbyssVideo.id,
playedDate: "2025-02-01T00:00:00+00:00",
});
[resp, body] = await getMovie(bubble.slug, {});
expectStatus(resp, body).toBe(200);
expect(body.watchStatus).toMatchObject({
percent: 100,
status: "completed",
completedAt: "2025-02-02 00:00:00+00",
});
[resp, body] = await getNextup("me", {});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
expect(body.items[0].slug).toBe(miaEntrySlug);
expect(body.items[0].progress).toMatchObject({
percent: 58,
time: 28 * 60 + 12,
videoId: madeInAbyssVideo.id,
playedDate: "2025-02-01 00:00:00+00",
});
});
it("history completed picks next", async () => {
let [resp, body] = await addToHistory("me", [
{
entry: miaEntrySlug,
videoId: madeInAbyssVideo.id,
percent: 98,
time: 28 * 60 + 12,
playedDate: "2025-02-05",
},
]);
expectStatus(resp, body).toBe(201);
expect(body.inserted).toBe(1);
[resp, body] = await getSerie(madeInAbyss.slug, {
with: ["nextEntry"],
});
expectStatus(resp, body).toBe(200);
expect(body.nextEntry).toBeObject();
expect(body.nextEntry.slug).toBe(miaNextEntrySlug);
expect(body.nextEntry.progress).toMatchObject({
percent: 0,
time: 0,
videoId: null,
playedDate: null,
});
[resp, body] = await getNextup("me", {});
expectStatus(resp, body).toBe(200);
expect(body.items).toBeArrayOfSize(1);
expect(body.items[0].slug).toBe(miaNextEntrySlug);
});
});