mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-31 20:24:27 -04:00
Add nextup/continue watching routes (#883)
This commit is contained in:
commit
60764f6c06
1
api/drizzle/0019_nextup.sql
Normal file
1
api/drizzle/0019_nextup.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "kyoo"."watchlist" ADD COLUMN "last_played_at" timestamp with time zone;
|
1845
api/drizzle/meta/0019_snapshot.json
Normal file
1845
api/drizzle/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -134,6 +134,13 @@
|
|||||||
"when": 1744053556621,
|
"when": 1744053556621,
|
||||||
"tag": "0018_history",
|
"tag": "0018_history",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1744120518941,
|
||||||
|
"tag": "0019_nextup",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { auth } from "./auth";
|
|||||||
import { entriesH } from "./controllers/entries";
|
import { entriesH } from "./controllers/entries";
|
||||||
import { imagesH } from "./controllers/images";
|
import { imagesH } from "./controllers/images";
|
||||||
import { historyH } from "./controllers/profiles/history";
|
import { historyH } from "./controllers/profiles/history";
|
||||||
|
import { nextup } from "./controllers/profiles/nextup";
|
||||||
import { watchlistH } from "./controllers/profiles/watchlist";
|
import { watchlistH } from "./controllers/profiles/watchlist";
|
||||||
import { seasonsH } from "./controllers/seasons";
|
import { seasonsH } from "./controllers/seasons";
|
||||||
import { seed } from "./controllers/seed";
|
import { seed } from "./controllers/seed";
|
||||||
@ -80,7 +81,10 @@ export const app = new Elysia({ prefix })
|
|||||||
.use(seasonsH)
|
.use(seasonsH)
|
||||||
.use(studiosH)
|
.use(studiosH)
|
||||||
.use(staffH)
|
.use(staffH)
|
||||||
.use(imagesH),
|
.use(imagesH)
|
||||||
|
.use(watchlistH)
|
||||||
|
.use(historyH)
|
||||||
|
.use(nextup),
|
||||||
)
|
)
|
||||||
.guard(
|
.guard(
|
||||||
{
|
{
|
||||||
@ -95,6 +99,4 @@ export const app = new Elysia({ prefix })
|
|||||||
permissions: ["core.write"],
|
permissions: ["core.write"],
|
||||||
},
|
},
|
||||||
(app) => app.use(videosH).use(seed),
|
(app) => app.use(videosH).use(seed),
|
||||||
)
|
);
|
||||||
.use(watchlistH)
|
|
||||||
.use(historyH);
|
|
||||||
|
@ -1,15 +1,4 @@
|
|||||||
import {
|
import { and, count, eq, exists, gt, isNotNull, ne, sql } from "drizzle-orm";
|
||||||
and,
|
|
||||||
count,
|
|
||||||
eq,
|
|
||||||
exists,
|
|
||||||
gt,
|
|
||||||
isNotNull,
|
|
||||||
ne,
|
|
||||||
not,
|
|
||||||
or,
|
|
||||||
sql,
|
|
||||||
} from "drizzle-orm";
|
|
||||||
import { alias } from "drizzle-orm/pg-core";
|
import { alias } from "drizzle-orm/pg-core";
|
||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
import { auth, getUserInfo } from "~/auth";
|
import { auth, getUserInfo } from "~/auth";
|
||||||
@ -181,6 +170,12 @@ export const historyH = new Elysia({ tags: ["profiles"] })
|
|||||||
const vals = values(
|
const vals = values(
|
||||||
body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })),
|
body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })),
|
||||||
).as("hist");
|
).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
|
const rows = await db
|
||||||
.insert(history)
|
.insert(history)
|
||||||
@ -195,19 +190,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
|
|||||||
playedDate: sql`hist.playedDate::timestamptz`,
|
playedDate: sql`hist.playedDate::timestamptz`,
|
||||||
})
|
})
|
||||||
.from(vals)
|
.from(vals)
|
||||||
.innerJoin(
|
.innerJoin(entries, valEqEntries)
|
||||||
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(videos, eq(videos.id, sql`hist.videoId::uuid`)),
|
.leftJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)),
|
||||||
)
|
)
|
||||||
.returning({ pk: history.pk });
|
.returning({ pk: history.pk });
|
||||||
@ -223,10 +206,11 @@ export const historyH = new Elysia({ tags: ["profiles"] })
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(nextEntry.showPk, entries.showPk),
|
eq(nextEntry.showPk, entries.showPk),
|
||||||
|
ne(nextEntry.kind, "extra"),
|
||||||
gt(nextEntry.order, entries.order),
|
gt(nextEntry.order, entries.order),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(nextEntry.showPk, entries.order)
|
.orderBy(nextEntry.order)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.as("nextEntryQ");
|
.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
|
await db
|
||||||
.insert(watchlist)
|
.insert(watchlist)
|
||||||
.select(
|
.select(
|
||||||
@ -273,9 +262,15 @@ export const historyH = new Elysia({ tags: ["profiles"] })
|
|||||||
else 0
|
else 0
|
||||||
end
|
end
|
||||||
`,
|
`,
|
||||||
nextEntry: nextEntryQ.pk,
|
nextEntry: sql`
|
||||||
|
case
|
||||||
|
when hist.percent::integer >= 95 then ${nextEntryQ.pk}
|
||||||
|
else ${entries.pk}
|
||||||
|
end
|
||||||
|
`,
|
||||||
score: sql`null`,
|
score: sql`null`,
|
||||||
startedAt: sql`hist.playedDate::timestamptz`,
|
startedAt: sql`hist.playedDate::timestamptz`,
|
||||||
|
lastPlayedAt: sql`hist.playedDate::timestamptz`,
|
||||||
completedAt: sql`
|
completedAt: sql`
|
||||||
case
|
case
|
||||||
when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz
|
when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz
|
||||||
@ -286,19 +281,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
|
|||||||
updatedAt: sql`now()`,
|
updatedAt: sql`now()`,
|
||||||
})
|
})
|
||||||
.from(vals)
|
.from(vals)
|
||||||
.leftJoin(
|
.leftJoin(entries, valEqEntries)
|
||||||
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`),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.leftJoinLateral(nextEntryQ, sql`true`),
|
.leftJoinLateral(nextEntryQ, sql`true`),
|
||||||
)
|
)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
@ -314,13 +297,18 @@ export const historyH = new Elysia({ tags: ["profiles"] })
|
|||||||
else ${watchlist.status}
|
else ${watchlist.status}
|
||||||
end
|
end
|
||||||
`,
|
`,
|
||||||
seenCount: sql`${seenCountQ}`,
|
seenCount: sql`
|
||||||
|
case
|
||||||
|
when ${showKindQ} = 'movie' then excluded.seen_count
|
||||||
|
else ${seenCountQ}
|
||||||
|
end`,
|
||||||
nextEntry: sql`
|
nextEntry: sql`
|
||||||
case
|
case
|
||||||
when ${watchlist.status} = 'completed' then null
|
when ${watchlist.status} = 'completed' then null
|
||||||
else excluded.next_entry
|
else excluded.next_entry
|
||||||
end
|
end
|
||||||
`,
|
`,
|
||||||
|
lastPlayedAt: sql`excluded.last_played_at`,
|
||||||
completedAt: coalesce(
|
completedAt: coalesce(
|
||||||
watchlist.completedAt,
|
watchlist.completedAt,
|
||||||
sql`excluded.completed_at`,
|
sql`excluded.completed_at`,
|
||||||
|
149
api/src/controllers/profiles/nextup.ts
Normal file
149
api/src/controllers/profiles/nextup.ts
Normal 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
@ -8,12 +8,14 @@ import {
|
|||||||
watchStatusQ,
|
watchStatusQ,
|
||||||
} from "~/controllers/shows/logic";
|
} from "~/controllers/shows/logic";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { shows } from "~/db/schema";
|
import { entries, shows } from "~/db/schema";
|
||||||
import { watchlist } from "~/db/schema/watchlist";
|
import { watchlist } from "~/db/schema/watchlist";
|
||||||
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
|
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
|
||||||
|
import { Entry } from "~/models/entry";
|
||||||
import { KError } from "~/models/error";
|
import { KError } from "~/models/error";
|
||||||
import { bubble, madeInAbyss } from "~/models/examples";
|
import { bubble, madeInAbyss } from "~/models/examples";
|
||||||
import { Show } from "~/models/show";
|
import { Movie } from "~/models/movie";
|
||||||
|
import { Serie } from "~/models/serie";
|
||||||
import {
|
import {
|
||||||
AcceptLanguage,
|
AcceptLanguage,
|
||||||
DbMetadata,
|
DbMetadata,
|
||||||
@ -32,18 +34,38 @@ async function setWatchStatus({
|
|||||||
status,
|
status,
|
||||||
userId,
|
userId,
|
||||||
}: {
|
}: {
|
||||||
show: { pk: number; kind: "movie" | "serie" };
|
show:
|
||||||
status: SerieWatchStatus;
|
| { pk: number; kind: "movie" }
|
||||||
|
| { pk: number; kind: "serie"; entriesCount: number };
|
||||||
|
status: Omit<SerieWatchStatus, "seenCount">;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const profilePk = await getOrCreateProfile(userId);
|
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
|
const [ret] = await db
|
||||||
.insert(watchlist)
|
.insert(watchlist)
|
||||||
.values({
|
.values({
|
||||||
...status,
|
...status,
|
||||||
profilePk: profilePk,
|
profilePk: profilePk,
|
||||||
|
seenCount:
|
||||||
|
status.status === "completed"
|
||||||
|
? show.kind === "movie"
|
||||||
|
? 100
|
||||||
|
: show.entriesCount
|
||||||
|
: 0,
|
||||||
showPk: show.pk,
|
showPk: show.pk,
|
||||||
|
nextEntry:
|
||||||
|
status.status === "watching" || status.status === "rewatching"
|
||||||
|
? sql`${firstEntryQ}`
|
||||||
|
: sql`null`,
|
||||||
|
lastPlayedAt: status.startedAt,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [watchlist.profilePk, watchlist.showPk],
|
target: [watchlist.profilePk, watchlist.showPk],
|
||||||
@ -53,10 +75,32 @@ async function setWatchStatus({
|
|||||||
"showPk",
|
"showPk",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"seenCount",
|
"seenCount",
|
||||||
|
"nextEntry",
|
||||||
|
"lastPlayedAt",
|
||||||
]),
|
]),
|
||||||
// do not reset movie's progress during drop
|
...(status.status === "completed"
|
||||||
...(show.kind === "movie" && status.status !== "dropped"
|
? {
|
||||||
? { seenCount: sql`excluded.seen_count` }
|
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,
|
languages: langs,
|
||||||
preferOriginal: preferOriginal ?? settings.preferOriginal,
|
preferOriginal: preferOriginal ?? settings.preferOriginal,
|
||||||
|
relations: ["nextEntry"],
|
||||||
userId: sub,
|
userId: sub,
|
||||||
});
|
});
|
||||||
return createPage(items, { url, sort, limit });
|
return createPage(items, { url, sort, limit });
|
||||||
@ -128,7 +173,18 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
{ additionalProperties: true },
|
{ additionalProperties: true },
|
||||||
),
|
),
|
||||||
response: {
|
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,
|
422: KError,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -159,6 +215,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
),
|
),
|
||||||
languages: langs,
|
languages: langs,
|
||||||
preferOriginal: preferOriginal ?? settings.preferOriginal,
|
preferOriginal: preferOriginal ?? settings.preferOriginal,
|
||||||
|
relations: ["nextEntry"],
|
||||||
userId: uInfo.id,
|
userId: uInfo.id,
|
||||||
});
|
});
|
||||||
return createPage(items, { url, sort, limit });
|
return createPage(items, { url, sort, limit });
|
||||||
@ -179,7 +236,18 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||||
}),
|
}),
|
||||||
response: {
|
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,
|
403: KError,
|
||||||
404: {
|
404: {
|
||||||
...KError,
|
...KError,
|
||||||
@ -195,7 +263,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
"/series/:id/watchstatus",
|
"/series/:id/watchstatus",
|
||||||
async ({ params: { id }, body, jwt: { sub }, error }) => {
|
async ({ params: { id }, body, jwt: { sub }, error }) => {
|
||||||
const [show] = await db
|
const [show] = await db
|
||||||
.select({ pk: shows.pk })
|
.select({ pk: shows.pk, entriesCount: shows.entriesCount })
|
||||||
.from(shows)
|
.from(shows)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@ -211,7 +279,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return await setWatchStatus({
|
return await setWatchStatus({
|
||||||
show: { pk: show.pk, kind: "serie" },
|
show: { pk: show.pk, kind: "serie", entriesCount: show.entriesCount },
|
||||||
userId: sub,
|
userId: sub,
|
||||||
status: body,
|
status: body,
|
||||||
});
|
});
|
||||||
@ -224,7 +292,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
example: madeInAbyss.slug,
|
example: madeInAbyss.slug,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
body: SerieWatchStatus,
|
body: t.Omit(SerieWatchStatus, ["seenCount"]),
|
||||||
response: {
|
response: {
|
||||||
200: t.Intersect([SerieWatchStatus, DbMetadata]),
|
200: t.Intersect([SerieWatchStatus, DbMetadata]),
|
||||||
404: KError,
|
404: KError,
|
||||||
@ -258,8 +326,6 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
status: {
|
status: {
|
||||||
...body,
|
...body,
|
||||||
startedAt: body.completedAt,
|
startedAt: body.completedAt,
|
||||||
// for movies, watch-percent is stored in `seenCount`.
|
|
||||||
seenCount: body.status === "completed" ? 100 : 0,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -196,11 +196,7 @@ const showRelations = {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.as("firstEntry");
|
.as("firstEntry");
|
||||||
},
|
},
|
||||||
nextEntry: ({
|
nextEntry: ({ languages }: { languages: string[] }) => {
|
||||||
languages,
|
|
||||||
}: {
|
|
||||||
languages: string[];
|
|
||||||
}) => {
|
|
||||||
const transQ = db
|
const transQ = db
|
||||||
.selectDistinctOn([entryTranslations.pk])
|
.selectDistinctOn([entryTranslations.pk])
|
||||||
.from(entryTranslations)
|
.from(entryTranslations)
|
||||||
|
@ -30,6 +30,7 @@ export const watchlist = schema.table(
|
|||||||
score: integer(),
|
score: integer(),
|
||||||
|
|
||||||
startedAt: timestamp({ withTimezone: true, mode: "string" }),
|
startedAt: timestamp({ withTimezone: true, mode: "string" }),
|
||||||
|
lastPlayedAt: timestamp({ withTimezone: true, mode: "string" }),
|
||||||
completedAt: timestamp({ withTimezone: true, mode: "string" }),
|
completedAt: timestamp({ withTimezone: true, mode: "string" }),
|
||||||
|
|
||||||
createdAt: timestamp({ withTimezone: true, mode: "string" })
|
createdAt: timestamp({ withTimezone: true, mode: "string" })
|
||||||
|
@ -82,6 +82,7 @@ export const FullSerie = t.Intersect([
|
|||||||
translations: t.Optional(TranslationRecord(SerieTranslation)),
|
translations: t.Optional(TranslationRecord(SerieTranslation)),
|
||||||
studios: t.Optional(t.Array(Studio)),
|
studios: t.Optional(t.Array(Studio)),
|
||||||
firstEntry: t.Optional(Entry),
|
firstEntry: t.Optional(Entry),
|
||||||
|
nextEntry: t.Optional(t.Nullable(Entry)),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
export type FullSerie = Prettify<typeof FullSerie.static>;
|
export type FullSerie = Prettify<typeof FullSerie.static>;
|
||||||
|
@ -184,7 +184,10 @@ export const getNews = async ({
|
|||||||
return [resp, body] as const;
|
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(
|
const resp = await app.handle(
|
||||||
new Request(buildUrl(`series/${id}/watchstatus`), {
|
new Request(buildUrl(`series/${id}/watchstatus`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -58,3 +58,33 @@ export const getWatchlist = async (
|
|||||||
const body = await resp.json();
|
const body = await resp.json();
|
||||||
return [resp, body] as const;
|
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;
|
||||||
|
};
|
||||||
|
170
api/tests/series/nextup.test.ts
Normal file
170
api/tests/series/nextup.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user