Remove entry fk in history

This commit is contained in:
Zoe Roux 2025-12-15 10:40:54 +01:00
parent 3b6234de46
commit 333dc46ebf
No known key found for this signature in database
7 changed files with 2135 additions and 77 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE "kyoo"."history" DROP CONSTRAINT "history_entry_pk_entries_pk_fk";
--> statement-breakpoint
ALTER TABLE "kyoo"."history" DROP COLUMN "entry_pk";

File diff suppressed because it is too large Load Diff

View File

@ -176,6 +176,13 @@
"when": 1763932730557,
"tag": "0024_fix-season-count",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1765791459003,
"tag": "0025_remove-history-entry",
"breakpoints": true
}
]
}

View File

@ -45,18 +45,19 @@ import { desc as description } from "~/models/utils/descriptions";
import type { EmbeddedVideo } from "~/models/video";
export const entryProgressQ = db
.selectDistinctOn([history.entryPk], {
.selectDistinctOn([entryVideoJoin.entryPk], {
percent: history.percent,
time: history.time,
entryPk: history.entryPk,
entryPk: entryVideoJoin.entryPk,
playedDate: history.playedDate,
videoId: videos.id,
})
.from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk))
.innerJoin(videos, eq(history.videoPk, videos.pk))
.innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.innerJoin(profiles, eq(history.profilePk, profiles.pk))
.where(eq(profiles.id, sql.placeholder("userId")))
.orderBy(history.entryPk, desc(history.playedDate))
.orderBy(entryVideoJoin.entryPk, desc(history.playedDate))
.as("progress");
export const entryFilters: FilterDef = {

View File

@ -44,7 +44,23 @@ import {
} from "../entries";
import { getOrCreateProfile } from "./profile";
export async function updateHistory(
export async function updateProgress(userPk: number, progress: SeedHistory[]) {
return db.transaction(async (tx) => {
const hist = await updateHistory(tx, userPk, progress);
if (hist.created.length + hist.updated.length !== progress.length) {
tx.rollback();
}
// only return new and entries whose status has changed.
// we don't need to update the watchlist every 10s when watching a video.
await updateWatchlist(tx, userPk, [
...hist.created,
...hist.updated.filter((x) => x.percent >= 95),
]);
return { status: 201, inserted: hist.created.length };
});
}
async function updateHistory(
dbTx: Transaction,
userPk: number,
progress: SeedHistory[],
@ -73,69 +89,76 @@ export async function updateHistory(
progress.filter((x) => !existing.includes(x.videoId)),
);
// TODO: only call update/insert if toUpdate/newEntries aren't empty
const updated = await tx
.update(history)
.set({
time: sql`hist.ts`,
percent: sql`hist.percent`,
playedDate: coalesce(sql`hist.played_date`, sql`now()`),
})
.from(sql`unnest(
${sqlarr(toUpdate.videoId)}::uuid[],
${sqlarr(toUpdate.time)}::integer[],
${sqlarr(toUpdate.percent)}::integer[],
${sqlarr(toUpdate.playedDate)}::timestamp[]
) as hist(video_id, ts, percent, played_date)`)
.innerJoin(videos, eq(videos.id, sql`hist.video_id`))
.where(and(eq(history.profilePk, userPk), eq(history.videoPk, videos.pk)))
.returning({
entryPk: history.entryPk,
percent: history.percent,
playedDate: history.playedDate,
});
const updated =
toUpdate === null
? []
: await tx
.update(history)
.set({
time: sql`hist.ts`,
percent: sql`hist.percent`,
playedDate: coalesce(sql`hist.played_date`, sql`now()`),
})
.from(sql`unnest(
${sqlarr(toUpdate.videoId)}::uuid[],
${sqlarr(toUpdate.time)}::integer[],
${sqlarr(toUpdate.percent)}::integer[],
${sqlarr(toUpdate.playedDate)}::timestamp[]
) as hist(video_id, ts, percent, played_date)`)
.innerJoin(videos, eq(videos.id, sql`hist.video_id`))
.where(
and(
eq(history.profilePk, userPk),
eq(history.videoPk, videos.pk),
),
)
.returning({
videoPk: history.videoPk,
percent: history.percent,
playedDate: history.playedDate,
});
const ret = await tx
.insert(history)
.select(
db
.select({
profilePk: sql`${userPk}`.as("profilePk"),
entryPk: entries.pk,
videoPk: videos.pk,
percent: sql`hist.percent`.as("percent"),
time: sql`hist.ts`.as("time"),
playedDate: coalesce(sql`hist.played_date`, sql`now()`).as(
"playedDate",
),
})
.from(sql`unnest(
${sqlarr(newEntries.videoId)}::uuid[],
${sqlarr(newEntries.time)}::integer[],
${sqlarr(newEntries.percent)}::integer[],
${sqlarr(newEntries.playedDate)}::timestamptz[]
) as hist(video_id, ts, percent, played_date)`)
.innerJoin(videos, eq(videos.id, sql`hist.videoId`))
.leftJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk))
.leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)),
)
.returning({
entryPk: history.entryPk,
percent: history.percent,
playedDate: history.playedDate,
});
const created =
newEntries === null
? []
: await tx
.insert(history)
.select(
db
.select({
profilePk: sql`${userPk}`.as("profilePk"),
videoPk: videos.pk,
percent: sql`hist.percent`.as("percent"),
time: sql`hist.ts`.as("time"),
playedDate: coalesce(sql`hist.played_date`, sql`now()`).as(
"playedDate",
),
})
.from(sql`unnest(
${sqlarr(newEntries.videoId)}::uuid[],
${sqlarr(newEntries.time)}::integer[],
${sqlarr(newEntries.percent)}::integer[],
${sqlarr(newEntries.playedDate)}::timestamptz[]
) as hist(video_id, ts, percent, played_date)`)
.innerJoin(videos, eq(videos.id, sql`hist.video_id`)),
)
.returning({
videoPk: history.videoPk,
percent: history.percent,
playedDate: history.playedDate,
});
// only return new and entries whose status has changed.
// we don't need to update the watchlist every 10s when watching a video.
return [...ret, ...updated.filter((x) => x.percent >= 95)];
return { created, updated };
});
}
export async function updateWatchlist(
async function updateWatchlist(
tx: Transaction,
userPk: number,
histArr: Awaited<ReturnType<typeof updateHistory>>,
histArr: { videoPk: number; percent: number; playedDate: string }[],
) {
if (histArr.length === 0) return;
const nextEntry = alias(entries, "next_entry");
const nextEntryQ = tx
.select({
@ -163,10 +186,14 @@ export async function updateWatchlist(
db
.select()
.from(history)
.leftJoin(
entryVideoJoin,
eq(history.videoPk, entryVideoJoin.videoPk),
)
.where(
and(
eq(history.profilePk, userPk),
eq(history.entryPk, entries.pk),
eq(entryVideoJoin.entryPk, entries.pk),
),
),
),
@ -178,7 +205,7 @@ export async function updateWatchlist(
.from(shows)
.where(eq(shows.pk, sql`excluded.show_pk`));
const hist = traverse(histArr);
const hist = traverse(histArr)!;
await tx
.insert(watchlist)
.select(
@ -221,11 +248,15 @@ export async function updateWatchlist(
updatedAt: sql`now()`.as("updatedAt"),
})
.from(sql`unnest(
${hist.entryPk}::integer[],
${hist.percent}::integer[],
${hist.playedDate}::timestamptz[]
) as hist(entry_pk, percent, played_date)`)
.leftJoin(entries, eq(entries.pk, sql`hist.entry_pk`))
${sqlarr(hist.videoPk)}::integer[],
${sqlarr(hist.percent)}::integer[],
${sqlarr(hist.playedDate)}::timestamptz[]
) as hist(video_pk, percent, played_date)`)
.innerJoin(
entryVideoJoin,
eq(sql`hist.video_pk`, entryVideoJoin.videoPk),
)
.leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk))
.leftJoinLateral(nextEntryQ, sql`true`),
)
.onConflictDoUpdate({
@ -261,17 +292,19 @@ export async function updateWatchlist(
});
}
// this one is different than the normal progressQ because we want duplicates
const historyProgressQ: typeof entryProgressQ = db
.select({
percent: history.percent,
time: history.time,
entryPk: history.entryPk,
entryPk: entryVideoJoin.entryPk,
playedDate: history.playedDate,
videoId: videos.id,
})
.from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk))
.leftJoin(profiles, eq(history.profilePk, profiles.pk))
.innerJoin(videos, eq(history.videoPk, videos.pk))
.innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.innerJoin(profiles, eq(history.profilePk, profiles.pk))
.where(eq(profiles.id, sql.placeholder("userId")))
.as("progress");

View File

@ -1,6 +1,5 @@
import { sql } from "drizzle-orm";
import { check, index, integer } from "drizzle-orm/pg-core";
import { entries } from "./entries";
import { profiles } from "./profiles";
import { schema, timestamp } from "./utils";
import { videos } from "./videos";
@ -12,10 +11,7 @@ export const history = schema.table(
profilePk: integer()
.notNull()
.references(() => profiles.pk, { onDelete: "cascade" }),
entryPk: integer()
.notNull()
.references(() => entries.pk, { onDelete: "cascade" }),
videoPk: integer().references(() => videos.pk, { onDelete: "set null" }),
videoPk: integer().notNull().references(() => videos.pk, { onDelete: "cascade" }),
percent: integer().notNull().default(0),
time: integer().notNull().default(0),
playedDate: timestamp({ withTimezone: true, mode: "iso" })

View File

@ -41,9 +41,10 @@ export function uniqBy<T>(a: T[], key: (val: T) => string): T[] {
export function traverse<T extends Record<string, any>>(
arr: T[],
): { [K in keyof T]: T[K][] } {
const result = {} as { [K in keyof T]: T[K][] };
): { [K in keyof T]: T[K][] } | null {
if (arr.length === 0) return null;
const result = {} as { [K in keyof T]: T[K][] };
arr.forEach((obj, i) => {
for (const key in obj) {
if (!result[key]) {