Handle watchstatus of entries without history entry

This commit is contained in:
Zoe Roux 2026-03-31 17:55:39 +02:00
parent d3a929855a
commit f126a0592e
No known key found for this signature in database
8 changed files with 2082 additions and 8 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE "kyoo"."history" ADD COLUMN "external" boolean;--> statement-breakpoint
UPDATE "kyoo"."history" SET "external" = false;--> statement-breakpoint
ALTER TABLE "kyoo"."history" ALTER COLUMN "external" SET NOT NULL;--> statement-breakpoint

File diff suppressed because it is too large Load Diff

View File

@ -211,6 +211,13 @@
"when": 1774623568394,
"tag": "0029_next_refresh",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1774974162419,
"tag": "0030_external_hist",
"breakpoints": true
}
]
}

View File

@ -62,6 +62,7 @@ export const entryProgressQ = db
entryPk: history.entryPk,
playedDate: history.playedDate,
videoId: videos.id,
external: history.external,
})
.from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk))

View File

@ -76,7 +76,7 @@ async function updateHistory(
.select({ videoId: videos.id })
.from(history)
.for("update", { of: sql`history` as any })
.leftJoin(videos, eq(videos.pk, history.videoPk))
.innerJoin(videos, eq(videos.pk, history.videoPk))
.where(
and(
eq(history.profilePk, userPk),
@ -86,12 +86,16 @@ async function updateHistory(
).map((x) => x.videoId);
const toUpdate = traverse(
progress.filter((x) => existing.includes(x.videoId)),
progress.filter((x) => x.videoId && existing.includes(x.videoId)),
);
const newEntries = traverse(
progress
.filter((x) => !existing.includes(x.videoId))
.map((x) => ({ ...x, entryUseid: isUuid(x.entry) })),
.filter((x) => !x.videoId || !existing.includes(x.videoId))
.map((x) => ({
...x,
external: x.external ?? false,
entryUseid: isUuid(x.entry),
})),
);
const updated =
@ -140,6 +144,7 @@ async function updateHistory(
playedDate: coalesce(sql`hist.played_date`, sql`now()`).as(
"playedDate",
),
external: sql`hist.external`.as("external"),
})
.from(sql`unnest(
${sqlarr(newEntries.entry)}::text[],
@ -147,8 +152,9 @@ async function updateHistory(
${sqlarr(newEntries.videoId)}::uuid[],
${sqlarr(newEntries.time)}::integer[],
${sqlarr(newEntries.percent)}::integer[],
${sqlarr(newEntries.playedDate)}::timestamptz[]
) as hist(entry, entry_use_id, video_id, ts, percent, played_date)`)
${sqlarr(newEntries.playedDate)}::timestamptz[],
${sqlarr(newEntries.external)}::boolean[]
) as hist(entry, entry_use_id, video_id, ts, percent, played_date, external)`)
.innerJoin(
entries,
sql`
@ -244,7 +250,7 @@ async function updateWatchlist(
seenCount: sql`
case
when ${entries.kind} = 'movie' then hist.percent
when hist.percent >= 95 then 1
when hist.percent >= 95 then 100
else 0
end
`.as("seen_count"),
@ -315,6 +321,7 @@ const historyProgressQ: typeof entryProgressQ = db
entryPk: history.entryPk,
playedDate: history.playedDate,
videoId: videos.id,
external: history.external,
})
.from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk))
@ -360,6 +367,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
eq(entryProgressQ.external, false),
ne(entries.kind, "extra"),
filter,
),
@ -406,6 +414,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
eq(entryProgressQ.external, false),
ne(entries.kind, "extra"),
filter,
),

View File

@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { check, index, integer, timestamp } from "drizzle-orm/pg-core";
import { boolean, check, index, integer, timestamp } from "drizzle-orm/pg-core";
import { entries } from "./entries";
import { profiles } from "./profiles";
import { schema } from "./utils";
@ -23,6 +23,8 @@ export const history = schema.table(
playedDate: timestamp({ withTimezone: true, precision: 3 })
.notNull()
.defaultNow(),
// true if the user only marked the entry has seen and has not seen it on kyoo
external: boolean().notNull(),
},
(t) => [
index("history_play_date").on(t.playedDate.desc()),

View File

@ -33,6 +33,18 @@ export const SeedHistory = t.Intersect([
entry: t.String({
description: "Id or slug of the entry/movie you watched",
}),
external: t.Optional(
t.Boolean({
description: comment`
Set this to true if the user marked the entry as watched
without actually watching it on kyoo.
If true, it will not add it to the history but still mark it as
seen.
`,
default: false,
}),
),
}),
]);
export type SeedHistory = typeof SeedHistory.static;

View File

@ -29,8 +29,25 @@ export const EntryContext = ({
} & Partial<ComponentProps<typeof Menu>> &
Partial<ComponentProps<typeof IconButton>>) => {
// const downloader = useDownloader();
const account = useAccount();
const { t } = useTranslation();
const markAsSeenMutation = useMutation({
method: "POST",
path: ["api", "profiles", "me", "history"],
body: [
{
percent: 100,
entry: slug,
videoId: null,
time: 0,
playedDate: null,
external: true,
},
],
invalidate: null,
});
return (
<Menu
Trigger={IconButton}
@ -39,6 +56,13 @@ export const EntryContext = ({
{...tooltip(t("misc.more"))}
{...(props as any)}
>
{account && (
<Menu.Item
label={t("show.watchlistMark.completed")}
icon={watchListIcon("completed")}
onSelect={() => markAsSeenMutation.mutate()}
/>
)}
{serieSlug && (
<Menu.Item
label={t("home.episodeMore.goToShow")}