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
This commit is contained in:
2026-08-03 02:10:10 -07:00
parent 0889910687
commit 6a4040b868
2 changed files with 42 additions and 20 deletions
+11 -2
View File
@@ -47,6 +47,11 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
*/ */
let cueState: { url: string; state: 'loading' | 'playing' } | null = null; let cueState: { url: string; state: 'loading' | 'playing' } | null = null;
const watchStateRequests = new LatestRequest(); 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(); const playbacks = new LatestRequest();
function formatEpisodeIndex(item: ListedEpisode): string { function formatEpisodeIndex(item: ListedEpisode): string {
@@ -199,7 +204,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
const anime = selectedAnime(); const anime = selectedAnime();
if (!anime || items.length === 0) return; if (!anime || items.length === 0) return;
const request = watchStateRequests.begin(); const request = markWrites.begin();
const attempt = await capture(() => const attempt = await capture(() =>
api.setWatched({ api.setWatched({
sourceId: anime.sourceId, 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) { if (!attempt.ok) {
setStatus(describe(attempt.error), 'error'); setStatus(describe(attempt.error), 'error');
@@ -233,6 +238,9 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
if (isMarked === mark) changed += 1; if (isMarked === mark) changed += 1;
} }
paint(); 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 // 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. // 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. // A menu opened against the list that is going away has nothing left to act on.
closeContextMenu(); closeContextMenu();
watchStateRequests.cancel(); watchStateRequests.cancel();
markWrites.cancel();
playbacks.cancel(); playbacks.cancel();
listed = []; listed = [];
watched = new Set(); watched = new Set();
+19 -6
View File
@@ -30,6 +30,7 @@ import {
applySessionLifetimeSummary, applySessionLifetimeSummary,
reconcileStaleActiveSessions, reconcileStaleActiveSessions,
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables, rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
rebuildLifetimeSummariesInTransaction,
shouldBackfillLifetimeSummaries, shouldBackfillLifetimeSummaries,
} from './immersion-tracker/lifetime'; } from './immersion-tracker/lifetime';
import { import {
@@ -755,6 +756,12 @@ export class ImmersionTrackerService {
// Every row this creates would otherwise rebuild the lifetime summaries on // Every row this creates would otherwise rebuild the lifetime summaries on
// its own, and a season's worth of episodes arrives in one call. // its own, and a season's worth of episodes arrives in one call.
let needsLifetimeRebuild = false; let needsLifetimeRebuild = false;
// 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) { for (const episode of episodes) {
const statsPath = normalizeMediaPath(episode.statsPath); const statsPath = normalizeMediaPath(episode.statsPath);
if (!statsPath) continue; if (!statsPath) continue;
@@ -769,14 +776,20 @@ export class ImmersionTrackerService {
markVideoWatched(this.db, videoId, watched); markVideoWatched(this.db, videoId, watched);
changed += 1; changed += 1;
// Clearing the mark on what is playing right now would otherwise be undone if (!watched && this.sessionState?.videoId === videoId) clearedActiveVideo = true;
// the moment the session passes the completion threshold again.
if (!watched && this.sessionState?.videoId === videoId) {
this.sessionState.markedWatched = true;
}
} }
if (needsLifetimeRebuild) rebuildLifetimeSummaryTables(this.db); if (needsLifetimeRebuild) rebuildLifetimeSummariesInTransaction(this.db);
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
// 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; return changed;
} }