mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-02-28 22:20:09 -05:00
Fix watchlist apis & add delete watchlist api
This commit is contained in:
parent
04fef7bd20
commit
6d21eeab07
@ -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,
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user