Fix watchlist apis & add delete watchlist api

This commit is contained in:
Zoe Roux 2025-12-21 20:18:36 +01:00
parent 04fef7bd20
commit 6d21eeab07
No known key found for this signature in database
7 changed files with 190 additions and 77 deletions

View File

@ -10,7 +10,7 @@ import {
import { db } from "~/db";
import { entries, shows } from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
import { coalesce, getColumns, rowToModel } from "~/db/utils";
import { Entry } from "~/models/entry";
import { KError } from "~/models/error";
import { bubble, madeInAbyss } from "~/models/examples";
@ -26,9 +26,16 @@ import {
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";
import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
import {
MovieWatchStatus,
SeedMovieWatchStatus,
SeedSerieWatchStatus,
SerieWatchStatus,
} from "~/models/watchlist";
import { getOrCreateProfile } from "./profile";
console.log();
async function setWatchStatus({
show,
status,
@ -37,7 +44,7 @@ async function setWatchStatus({
show:
| { pk: number; kind: "movie" }
| { pk: number; kind: "serie"; entriesCount: number };
status: Omit<SerieWatchStatus, "seenCount">;
status: SeedSerieWatchStatus;
userId: string;
}) {
const profilePk = await getOrCreateProfile(userId);
@ -53,6 +60,7 @@ async function setWatchStatus({
.insert(watchlist)
.values({
...status,
startedAt: coalesce(sql`${status.startedAt ?? null}`, sql`now()`),
profilePk: profilePk,
seenCount:
status.status === "completed"
@ -70,38 +78,33 @@ async function setWatchStatus({
.onConflictDoUpdate({
target: [watchlist.profilePk, watchlist.showPk],
set: {
...conflictUpdateAllExcept(watchlist, [
"profilePk",
"showPk",
"createdAt",
"seenCount",
"nextEntry",
"lastPlayedAt",
]),
...(status.status === "completed"
? {
seenCount: sql`excluded.seen_count`,
nextEntry: sql`null`,
}
: {}),
status: sql`excluded.status`,
startedAt: coalesce(
sql`${status.startedAt ?? null}`,
watchlist.startedAt,
),
completedAt: sql`
case
when excluded.status = 'completed' then coalesce(excluded.completed_at, now())
else coalesce(excluded.completed_at, ${watchlist.completedAt})
end
`,
// 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`,
}
: {}),
seenCount: sql`
case
when excluded.status = 'completed' then excluded.seen_count
when excluded.status = 'rewatching' and ${watchlist.status} != 'rewatching' then excluded.seen_count
else ${watchlist.seenCount}
end
`,
nextEntry: sql`
case
when excluded.status = 'completed' then null
when excluded.status = 'rewatching' and ${watchlist.status} != 'rewatching' then excluded.next_entry
else ${watchlist.nextEntry}
end
`,
},
})
.returning({
@ -296,7 +299,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
example: madeInAbyss.slug,
}),
}),
body: t.Omit(SerieWatchStatus, ["seenCount"]),
body: SeedSerieWatchStatus,
response: {
200: t.Intersect([SerieWatchStatus, DbMetadata]),
404: KError,
@ -341,7 +344,88 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
example: bubble.slug,
}),
}),
body: t.Omit(MovieWatchStatus, ["percent"]),
body: SeedMovieWatchStatus,
response: {
200: t.Intersect([MovieWatchStatus, DbMetadata]),
404: KError,
},
permissions: ["core.read"],
},
)
.delete(
"/series/:id/watchstatus",
async ({ params: { id }, jwt: { sub }, status }) => {
const profilePk = await getOrCreateProfile(sub);
const rows = await db.execute(sql`
delete from ${watchlist} using ${shows}
where ${and(
eq(watchlist.profilePk, profilePk),
eq(watchlist.showPk, shows.pk),
eq(shows.kind, "serie"),
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
)}
returning ${watchlist}.*
`);
if (rows.rowCount === 0) {
return status(404, {
status: 404,
message: `No serie found in your watchlist the id/slug: '${id}'.`,
});
}
return rowToModel(rows.rows[0], watchlist);
},
{
detail: { description: "Set watchstatus of a serie." },
params: t.Object({
id: t.String({
description: "The id or slug of the serie.",
example: madeInAbyss.slug,
}),
}),
response: {
200: t.Intersect([SerieWatchStatus, DbMetadata]),
404: KError,
},
permissions: ["core.read"],
},
)
.delete(
"/movies/:id/watchstatus",
async ({ params: { id }, jwt: { sub }, status }) => {
const profilePk = await getOrCreateProfile(sub);
const rows = await db.execute(sql`
delete from ${watchlist} using ${shows}
where ${and(
eq(watchlist.profilePk, profilePk),
eq(watchlist.showPk, shows.pk),
eq(shows.kind, "movie"),
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
)}
returning ${watchlist}.*
`);
if (rows.rowCount === 0) {
return status(404, {
status: 404,
message: `No movie found in your watchlist the id/slug: '${id}'.`,
});
}
const ret = rowToModel(rows.rows[0], watchlist);
return { ...ret, percent: ret.seenCount };
},
{
detail: { description: "Set watchstatus of a movie." },
params: t.Object({
id: t.String({
description: "The id or slug of the movie.",
example: bubble.slug,
}),
}),
response: {
200: t.Intersect([MovieWatchStatus, DbMetadata]),
404: KError,

View File

@ -31,6 +31,7 @@ export const timestamp = customType<{
return `timestamp${precision}${config?.withTimezone ? " with time zone" : ""}`;
},
fromDriver(value: string): string {
if (!value) return value;
// postgres format: 2025-06-22 16:13:37.489301+00
// what we want: 2025-06-22T16:13:37Z
return `${value.substring(0, 10)}T${value.substring(11, 19)}Z`;

View File

@ -11,11 +11,13 @@ import {
type TableConfig,
View,
ViewBaseConfig,
InferSelectModel,
} from "drizzle-orm";
import type { CasingCache } from "drizzle-orm/casing";
import type { AnyMySqlSelect } from "drizzle-orm/mysql-core";
import type {
AnyPgSelect,
PgTable,
PgTableWithColumns,
SelectedFieldsFlat,
} from "drizzle-orm/pg-core";
@ -52,6 +54,21 @@ export function getColumns<
: table._.selectedFields;
}
// See https://github.com/drizzle-team/drizzle-orm/issues/2842
export function rowToModel<
TTable extends PgTable,
TModel = InferSelectModel<TTable>,
>(row: Record<string, unknown>, table: TTable): TModel {
// @ts-expect-error: drizzle internal
const casing = db.dialect.casing as CasingCache;
return Object.fromEntries(
Object.entries(table).map(([schemaName, schema]) => [
schemaName,
schema.mapFromDriverValue?.(row[casing.getColumnCasing(schema)] ?? null),
]),
) as TModel;
}
// See https://github.com/drizzle-team/drizzle-orm/issues/1728
export function conflictUpdateAllExcept<
T extends Table,

View File

@ -21,13 +21,24 @@ export const SerieWatchStatus = t.Object({
});
export type SerieWatchStatus = typeof SerieWatchStatus.static;
export const MovieWatchStatus = t.Composite([
t.Omit(SerieWatchStatus, ["startedAt", "seenCount"]),
t.Object({
percent: t.Integer({
minimum: 0,
maximum: 100,
}),
export const MovieWatchStatus = t.Object({
status: WatchlistStatus,
score: SerieWatchStatus.properties.score,
completedAt: SerieWatchStatus.properties.completedAt,
percent: t.Integer({
minimum: 0,
maximum: 100,
}),
]);
});
export type MovieWatchStatus = typeof MovieWatchStatus.static;
export const SeedSerieWatchStatus = t.Object({
status: SerieWatchStatus.properties.status,
score: t.Optional(SerieWatchStatus.properties.score),
startedAt: t.Optional(SerieWatchStatus.properties.startedAt),
completedAt: t.Optional(SerieWatchStatus.properties.completedAt),
});
export type SeedSerieWatchStatus = typeof SeedSerieWatchStatus.static;
export const SeedMovieWatchStatus = t.Omit(SeedSerieWatchStatus, ["startedAt"]);
export type SeedMovieWatchStatus = typeof SeedMovieWatchStatus.static;

View File

@ -70,19 +70,19 @@ export const ItemContext = ({
const { t } = useTranslation();
const mutation = useMutation({
path: [kind, slug, "watchStatus"],
path: ["api", `${kind}s`, slug, "watchstatus"],
compute: (newStatus: WatchStatusV | null) => ({
method: newStatus ? "POST" : "DELETE",
params: newStatus ? { status: newStatus } : undefined,
body: newStatus ? { status: newStatus } : undefined,
}),
invalidate: [kind, slug],
});
const metadataRefreshMutation = useMutation({
method: "POST",
path: [kind, slug, "refresh"],
invalidate: null,
});
// const metadataRefreshMutation = useMutation({
// method: "POST",
// path: [kind, slug, "refresh"],
// invalidate: null,
// });
return (
<Menu
@ -127,16 +127,16 @@ export const ItemContext = ({
/>
</>
)}
{account?.isAdmin === true && (
<>
<HR />
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
{/* {account?.isAdmin === true && ( */}
{/* <> */}
{/* <HR /> */}
{/* <Menu.Item */}
{/* label={t("home.refreshMetadata")} */}
{/* icon={Refresh} */}
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
{/* /> */}
{/* </> */}
{/* )} */}
</Menu>
);
};

View File

@ -46,7 +46,7 @@ export const WatchListInfo = ({
const { t } = useTranslation();
const mutation = useMutation({
path: [kind, slug, "watchStatus"],
path: ["api", `${kind}s`, slug, "watchstatus"],
compute: (newStatus: WatchStatus | null) => ({
method: newStatus ? "POST" : "DELETE",
body: newStatus ? { status: newStatus } : undefined,

View File

@ -58,7 +58,7 @@ import {
UL,
} from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { Fetch, type QueryIdentifier, useMutation } from "~/query";
import { Fetch, type QueryIdentifier } from "~/query";
import { displayRuntime, getDisplayDate } from "~/utils";
const ButtonList = ({
@ -78,11 +78,11 @@ const ButtonList = ({
const { css, theme } = useYoshiki();
const { t } = useTranslation();
const metadataRefreshMutation = useMutation({
method: "POST",
path: [kind, slug, "refresh"],
invalidate: null,
});
// const metadataRefreshMutation = useMutation({
// method: "POST",
// path: [kind, slug, "refresh"],
// invalidate: null,
// });
return (
<View
@ -143,16 +143,16 @@ const ButtonList = ({
/>
</>
)}
{account?.isAdmin === true && (
<>
{kind === "movie" && <HR />}
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
{/* {account?.isAdmin === true && ( */}
{/* <> */}
{/* {kind === "movie" && <HR />} */}
{/* <Menu.Item */}
{/* label={t("home.refreshMetadata")} */}
{/* icon={Refresh} */}
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
{/* /> */}
{/* </> */}
{/* )} */}
</Menu>
)}
</View>