fix(tracking): skip watched episodes in bulk marks; read ratings without status

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 16:10:45 -04:00
1 parent 65d070ff23
commit 68693415bf
2 files changed
+73 -15

No files matched your search

+45
View File
@@ -29,6 +29,7 @@ import {
unwatchMovie, unwatchMovie,
unwatchSeason, unwatchSeason,
unwatchSeries, unwatchSeries,
watchSeason,
} from "../src/tracking"; } from "../src/tracking";
beforeEach(() => { beforeEach(() => {
@@ -297,6 +298,20 @@ describe("logEpisodeWatchBatch", () => {
expect(row?.status).toBe("in_progress"); 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", () => { test("no-op for empty array", () => {
insertUser(); insertUser();
logEpisodeWatchBatch("user-1", []); 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 ────────────────────────────────────────── // ── markAllEpisodesWatched ──────────────────────────────────────────
describe("markAllEpisodesWatched", () => { describe("markAllEpisodesWatched", () => {
@@ -647,6 +682,16 @@ describe("getUserTitleInfo", () => {
expect(info.episodeWatches).toContain(episodeIds[1]); 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", () => { test("returns nulls when no data exists", () => {
insertUser(); insertUser();
insertTitle(); insertTitle();
+28 -15
View File
@@ -95,7 +95,23 @@ export function batchInsertEpisodeWatchesTransaction(
db.transaction((tx) => { db.transaction((tx) => {
const now = watchedAt ?? new Date(); 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(); 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) { export function getUserTitleInfo(userId: string, titleId: string) {
const info = db const statusRow = db
.select({ .select({ status: userTitleStatus.status })
status: userTitleStatus.status,
ratingStars: userRatings.ratingStars,
})
.from(userTitleStatus) .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))) .where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get(); .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 const watchedEpisodeIds = db
.selectDistinct({ episodeId: userEpisodeWatches.episodeId }) .selectDistinct({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches) .from(userEpisodeWatches)
@@ -280,8 +293,8 @@ export function getUserTitleInfo(userId: string, titleId: string) {
.map((w) => w.episodeId); .map((w) => w.episodeId);
return { return {
status: info?.status ?? null, status: statusRow?.status ?? null,
rating: info?.ratingStars ?? null, rating: ratingRow?.ratingStars ?? null,
episodeWatches: watchedEpisodeIds, episodeWatches: watchedEpisodeIds,
}; };
} }