mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-03 19:21:32 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user