From 6a4040b8680b746e8124f07f53b16c7762ce96ec Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 3 Aug 2026 02:10:10 -0700 Subject: [PATCH] fix(anime): isolate mark-watched requests and batch marks atomically - Track mark writes with their own LatestRequest so a concurrent watch-state refresh can't supersede them, and re-read the store after a write to settle stale repaints - Wrap batch markEpisodesWatched in a single transaction so partial rows and their lifetime summary rebuild can't land separately; defer clearing the active session's markedWatched flag until after commit --- src/animeui/episode-list.ts | 13 ++++- .../services/immersion-tracker-service.ts | 49 ++++++++++++------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/animeui/episode-list.ts b/src/animeui/episode-list.ts index 4cea9cf3..a08250b5 100644 --- a/src/animeui/episode-list.ts +++ b/src/animeui/episode-list.ts @@ -47,6 +47,11 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList */ let cueState: { url: string; state: 'loading' | 'playing' } | null = null; const watchStateRequests = new LatestRequest(); + /** + * Mark writes carry their own token: a background refresh starting mid-write + * must not make the write look superseded and drop its repaint on the floor. + */ + const markWrites = new LatestRequest(); const playbacks = new LatestRequest(); function formatEpisodeIndex(item: ListedEpisode): string { @@ -199,7 +204,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList const anime = selectedAnime(); if (!anime || items.length === 0) return; - const request = watchStateRequests.begin(); + const request = markWrites.begin(); const attempt = await capture(() => api.setWatched({ sourceId: anime.sourceId, @@ -213,7 +218,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList })), }), ); - if (!watchStateRequests.isCurrent(request)) return; + if (!markWrites.isCurrent(request)) return; if (!attempt.ok) { setStatus(describe(attempt.error), 'error'); @@ -233,6 +238,9 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList if (isMarked === mark) changed += 1; } paint(); + // A refresh that read the store before this write landed would repaint stale + // marks over the ones above, so re-read from the store to settle the list. + void refreshWatchState(); // Nothing came back marked when marking is what was asked for: the write // had nowhere to land, which is what a disabled stats history looks like. @@ -308,6 +316,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList // A menu opened against the list that is going away has nothing left to act on. closeContextMenu(); watchStateRequests.cancel(); + markWrites.cancel(); playbacks.cancel(); listed = []; watched = new Set(); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 2f34d5c2..b765c640 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -30,6 +30,7 @@ import { applySessionLifetimeSummary, reconcileStaleActiveSessions, rebuildLifetimeSummaries as rebuildLifetimeSummaryTables, + rebuildLifetimeSummariesInTransaction, shouldBackfillLifetimeSummaries, } from './immersion-tracker/lifetime'; import { @@ -755,28 +756,40 @@ export class ImmersionTrackerService { // Every row this creates would otherwise rebuild the lifetime summaries on // its own, and a season's worth of episodes arrives in one call. let needsLifetimeRebuild = false; - for (const episode of episodes) { - const statsPath = normalizeMediaPath(episode.statsPath); - if (!statsPath) continue; - if (watched) { - needsLifetimeRebuild = - this.recordStreamPlaybackMetadata(episode, { deferLifetimeRebuild: true }) || - needsLifetimeRebuild; + // A half-applied batch would leave rows created without their marks, so the + // whole span of episodes and the summary rebuild land together or not at all. + let clearedActiveVideo = false; + + this.db.exec('BEGIN IMMEDIATE'); + try { + for (const episode of episodes) { + const statsPath = normalizeMediaPath(episode.statsPath); + if (!statsPath) continue; + if (watched) { + needsLifetimeRebuild = + this.recordStreamPlaybackMetadata(episode, { deferLifetimeRebuild: true }) || + needsLifetimeRebuild; + } + + const videoId = getVideoIdByVideoKey(this.db, buildVideoKey(statsPath, SOURCE_TYPE_REMOTE)); + if (videoId === null) continue; + markVideoWatched(this.db, videoId, watched); + changed += 1; + + if (!watched && this.sessionState?.videoId === videoId) clearedActiveVideo = true; } - const videoId = getVideoIdByVideoKey(this.db, buildVideoKey(statsPath, SOURCE_TYPE_REMOTE)); - if (videoId === null) continue; - markVideoWatched(this.db, videoId, watched); - changed += 1; - - // Clearing the mark on what is playing right now would otherwise be undone - // the moment the session passes the completion threshold again. - if (!watched && this.sessionState?.videoId === videoId) { - this.sessionState.markedWatched = true; - } + if (needsLifetimeRebuild) rebuildLifetimeSummariesInTransaction(this.db); + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; } - if (needsLifetimeRebuild) rebuildLifetimeSummaryTables(this.db); + // Clearing the mark on what is playing right now would otherwise be undone + // the moment the session passes the completion threshold again. Set after the + // commit, so a rolled back clear does not suppress the automatic mark. + if (clearedActiveVideo && this.sessionState) this.sessionState.markedWatched = true; return changed; }