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;
|
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();
|
||||||
|
|||||||
@@ -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,28 +756,40 @@ 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;
|
||||||
for (const episode of episodes) {
|
// A half-applied batch would leave rows created without their marks, so the
|
||||||
const statsPath = normalizeMediaPath(episode.statsPath);
|
// whole span of episodes and the summary rebuild land together or not at all.
|
||||||
if (!statsPath) continue;
|
let clearedActiveVideo = false;
|
||||||
if (watched) {
|
|
||||||
needsLifetimeRebuild =
|
this.db.exec('BEGIN IMMEDIATE');
|
||||||
this.recordStreamPlaybackMetadata(episode, { deferLifetimeRebuild: true }) ||
|
try {
|
||||||
needsLifetimeRebuild;
|
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 (needsLifetimeRebuild) rebuildLifetimeSummariesInTransaction(this.db);
|
||||||
if (videoId === null) continue;
|
this.db.exec('COMMIT');
|
||||||
markVideoWatched(this.db, videoId, watched);
|
} catch (error) {
|
||||||
changed += 1;
|
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.
|
|
||||||
if (!watched && this.sessionState?.videoId === videoId) {
|
|
||||||
this.sessionState.markedWatched = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user