From 68693415bf4e00dee2035094fa25fbd2e079367c Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 4 Oct 2026 16:10:45 -0400 Subject: [PATCH] fix(tracking): skip watched episodes in bulk marks; read ratings without status Co-Authored-By: Claude Sonnet 5.5 --- packages/core/test/tracking.test.ts | 45 +++++++++++++++++++++++++++++ packages/db/src/queries/tracking.ts | 43 +++++++++++++++++---------- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/packages/core/test/tracking.test.ts b/packages/core/test/tracking.test.ts index 5142d62..4f97024 100644 --- a/packages/core/test/tracking.test.ts +++ b/packages/core/test/tracking.test.ts @@ -29,6 +29,7 @@ import { unwatchMovie, unwatchSeason, unwatchSeries, + watchSeason, } from "../src/tracking"; beforeEach(() => { @@ -297,6 +298,20 @@ describe("logEpisodeWatchBatch", () => { expect(row?.status).toBe("in_progress"); }); + test("skips episodes that are already watched", () => { + insertUser(); + const { episodeIds } = insertTvShow("tv-1", 99999, 1, 3); + logEpisodeWatch("user-1", episodeIds[0]); + logEpisodeWatchBatch("user-1", episodeIds); + + const watches = testDb + .select() + .from(userEpisodeWatches) + .where(eq(userEpisodeWatches.userId, "user-1")) + .all(); + expect(watches).toHaveLength(3); + }); + test("no-op for empty array", () => { insertUser(); logEpisodeWatchBatch("user-1", []); @@ -310,6 +325,26 @@ describe("logEpisodeWatchBatch", () => { }); }); +// ── watchSeason ───────────────────────────────────────────────────── + +describe("watchSeason", () => { + test("does not duplicate already-watched episodes", () => { + insertUser(); + const { episodeIds } = insertTvShow("tv-1", 99999, 1, 3); + logEpisodeWatch("user-1", episodeIds[0]); + logEpisodeWatch("user-1", episodeIds[1]); + watchSeason("user-1", "tv-1-s1"); + + const watches = testDb + .select() + .from(userEpisodeWatches) + .where(eq(userEpisodeWatches.userId, "user-1")) + .all(); + expect(watches).toHaveLength(3); + expect(new Set(watches.map((w) => w.episodeId)).size).toBe(3); + }); +}); + // ── markAllEpisodesWatched ────────────────────────────────────────── describe("markAllEpisodesWatched", () => { @@ -647,6 +682,16 @@ describe("getUserTitleInfo", () => { expect(info.episodeWatches).toContain(episodeIds[1]); }); + test("returns a rating for a title not in the library", () => { + insertUser(); + insertTitle(); + rateTitleStars("user-1", "title-1", 4); + + const info = getUserTitleInfo("user-1", "title-1"); + expect(info.rating).toBe(4); + expect(info.status).toBeNull(); + }); + test("returns nulls when no data exists", () => { insertUser(); insertTitle(); diff --git a/packages/db/src/queries/tracking.ts b/packages/db/src/queries/tracking.ts index 73c8de7..ace833f 100644 --- a/packages/db/src/queries/tracking.ts +++ b/packages/db/src/queries/tracking.ts @@ -95,7 +95,23 @@ export function batchInsertEpisodeWatchesTransaction( db.transaction((tx) => { const now = watchedAt ?? new Date(); - for (const episodeId of episodeIds) { + // Bulk marking records state, not rewatches (single-episode logEpisodeWatch is the rewatch path). + const alreadyWatched = new Set( + tx + .select({ episodeId: userEpisodeWatches.episodeId }) + .from(userEpisodeWatches) + .where( + and( + eq(userEpisodeWatches.userId, userId), + inArray(userEpisodeWatches.episodeId, episodeIds), + ), + ) + .all() + .map((w) => w.episodeId), + ); + + for (const episodeId of new Set(episodeIds)) { + if (alreadyWatched.has(episodeId)) continue; tx.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt: now, source }).run(); } @@ -254,22 +270,19 @@ export function getEpisodeProgressByTitleIds(userId: string, titleIds: string[]) } export function getUserTitleInfo(userId: string, titleId: string) { - const info = db - .select({ - status: userTitleStatus.status, - ratingStars: userRatings.ratingStars, - }) + const statusRow = db + .select({ status: userTitleStatus.status }) .from(userTitleStatus) - .leftJoin( - userRatings, - and( - eq(userRatings.userId, userTitleStatus.userId), - eq(userRatings.titleId, userTitleStatus.titleId), - ), - ) .where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId))) .get(); + // Ratings exist independently of library membership (rateTitleStars doesn't add a status row). + const ratingRow = db + .select({ ratingStars: userRatings.ratingStars }) + .from(userRatings) + .where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId))) + .get(); + const watchedEpisodeIds = db .selectDistinct({ episodeId: userEpisodeWatches.episodeId }) .from(userEpisodeWatches) @@ -280,8 +293,8 @@ export function getUserTitleInfo(userId: string, titleId: string) { .map((w) => w.episodeId); return { - status: info?.status ?? null, - rating: info?.ratingStars ?? null, + status: statusRow?.status ?? null, + rating: ratingRow?.ratingStars ?? null, episodeWatches: watchedEpisodeIds, }; }