fix: sync AniList after seeked completion

This commit is contained in:
2026-05-03 21:07:13 -07:00
parent 040741cf57
commit 69d5cc7557
10 changed files with 155 additions and 20 deletions
@@ -19,8 +19,14 @@ AniList episode progress should sync reliably when playback reaches or passes th
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 When playback moves from before the completion threshold to any later position at or beyond the threshold, AniList queues or sends the episode progress update once. - [x] #1 When playback moves from before the completion threshold to any later position at or beyond the threshold, AniList queues or sends the episode progress update once.
- [ ] #2 If playback is already past the completion threshold and the update has not yet been recorded for the current media/episode, AniList still queues or sends the update. - [x] #2 If playback is already past the completion threshold and the update has not yet been recorded for the current media/episode, AniList still queues or sends the update.
- [ ] #3 AniList progress updates remain deduplicated for the same media/episode watch completion. - [x] #3 AniList progress updates remain deduplicated for the same media/episode watch completion.
- [ ] #4 A regression test covers the skipped-threshold or already-past-threshold case. - [x] #4 A regression test covers the skipped-threshold or already-past-threshold case.
<!-- AC:END --> <!-- AC:END -->
## Notes
- Fixed mpv `time-pos` ordering so post-watch checks read the fresh playback position after seeks.
- Wired manual mark-watched to run a forced AniList post-watch sync after the local watched mark succeeds.
- Added regressions for time-position ordering, manual watched sync, forced post-watch updates, and the Little Witch Academia filename parse.
@@ -1,4 +1,4 @@
type: fixed type: fixed
area: anilist area: anilist
- Anilist: Run post-watch progress checks on mpv time-position updates and fill missing `guessit` episode metadata from the filename parser so completed episodes are less likely to miss progress sync. - AniList: Run post-watch progress checks on mpv time-position updates, read the fresh mpv position before threshold checks, wire manual mark-watched to force a progress sync, and fill missing `guessit` episode metadata from the filename parser.
+48
View File
@@ -302,6 +302,54 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.equal(deps.getPlaybackPaused(), true); assert.equal(deps.getPlaybackPaused(), true);
}); });
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
immersionTracker: createFakeImmersionTracker({
markActiveVideoWatched: async () => {
calls.push('mark');
return true;
},
}),
runAnilistPostWatchUpdateOnManualMark: async () => {
calls.push('anilist');
},
}),
registrar,
);
const result = await handlers.handle.get(IPC_CHANNELS.command.markActiveVideoWatched)?.({});
assert.equal(result, true);
assert.deepEqual(calls, ['mark', 'anilist']);
});
test('registerIpcHandlers skips AniList update when manual mark watched has no active session', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
immersionTracker: createFakeImmersionTracker({
markActiveVideoWatched: async () => {
calls.push('mark');
return false;
},
}),
runAnilistPostWatchUpdateOnManualMark: async () => {
calls.push('anilist');
},
}),
registrar,
);
const result = await handlers.handle.get(IPC_CHANNELS.command.markActiveVideoWatched)?.({});
assert.equal(result, false);
assert.deepEqual(calls, ['mark']);
});
test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => { test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<[string, unknown[]]> = []; const calls: Array<[string, unknown[]]> = [];
+8 -1
View File
@@ -90,6 +90,7 @@ export interface IpcServiceDeps {
openAnilistSetup: () => void; openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: () => Promise<unknown>; getCharacterDictionarySelection?: () => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>; setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string }; appendClipboardVideoToQueue: () => { ok: boolean; message: string };
@@ -213,6 +214,7 @@ export interface IpcDepsRuntimeOptions {
openAnilistSetup: () => void; openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: () => Promise<unknown>; getCharacterDictionarySelection?: () => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>; setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string }; appendClipboardVideoToQueue: () => { ok: boolean; message: string };
@@ -288,6 +290,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
openAnilistSetup: options.openAnilistSetup, openAnilistSetup: options.openAnilistSetup,
getAnilistQueueStatus: options.getAnilistQueueStatus, getAnilistQueueStatus: options.getAnilistQueueStatus,
retryAnilistQueueNow: options.retryAnilistQueueNow, retryAnilistQueueNow: options.retryAnilistQueueNow,
runAnilistPostWatchUpdateOnManualMark: options.runAnilistPostWatchUpdateOnManualMark,
getCharacterDictionarySelection: getCharacterDictionarySelection:
options.getCharacterDictionarySelection ?? options.getCharacterDictionarySelection ??
(async () => ({ (async () => ({
@@ -385,7 +388,11 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
}); });
ipc.handle(IPC_CHANNELS.command.markActiveVideoWatched, async () => { ipc.handle(IPC_CHANNELS.command.markActiveVideoWatched, async () => {
return (await deps.immersionTracker?.markActiveVideoWatched()) ?? false; const marked = (await deps.immersionTracker?.markActiveVideoWatched()) ?? false;
if (marked) {
await deps.runAnilistPostWatchUpdateOnManualMark?.();
}
return marked;
}); });
ipc.on(IPC_CHANNELS.command.quitApp, () => { ipc.on(IPC_CHANNELS.command.quitApp, () => {
+19
View File
@@ -281,6 +281,25 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
}); });
}); });
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
const calls: string[] = [];
let currentTimePos = 0;
const { deps } = createDeps({
setCurrentTimePos: (time) => {
currentTimePos = time;
calls.push(`set:${time}`);
},
getCurrentTimePos: () => currentTimePos,
emitTimePosChange: ({ time }) => {
calls.push(`emit:${time}:current=${currentTimePos}`);
},
});
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'time-pos', data: 90 }, deps);
assert.deepEqual(calls, ['set:90', 'emit:90:current=90']);
});
test('splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer', () => { test('splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer', () => {
const parsed = splitMpvMessagesFromBuffer( const parsed = splitMpvMessagesFromBuffer(
'{"event":"shutdown"}\n{"event":"property-change","name":"media-title","data":"x"}\n{"partial"', '{"event":"shutdown"}\n{"event":"property-change","name":"media-title","data":"x"}\n{"partial"',
+3 -2
View File
@@ -276,8 +276,9 @@ export async function dispatchMpvProtocolMessage(
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null); deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
deps.syncCurrentAudioStreamIndex(); deps.syncCurrentAudioStreamIndex();
} else if (msg.name === 'time-pos') { } else if (msg.name === 'time-pos') {
deps.emitTimePosChange({ time: (msg.data as number) || 0 }); const timePos = (msg.data as number) || 0;
deps.setCurrentTimePos((msg.data as number) || 0); deps.setCurrentTimePos(timePos);
deps.emitTimePosChange({ time: timePos });
if ( if (
deps.getPauseAtTime() !== null && deps.getPauseAtTime() !== null &&
deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number) deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number)
+1
View File
@@ -4947,6 +4947,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openAnilistSetup: () => openAnilistSetupWindow(), openAnilistSetup: () => openAnilistSetupWindow(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
getCharacterDictionarySelection: () => getCharacterDictionarySelection: () =>
characterDictionaryRuntime.getManualSelectionSnapshot(), characterDictionaryRuntime.getManualSelectionSnapshot(),
setCharacterDictionarySelection: async (mediaId: number) => setCharacterDictionarySelection: async (mediaId: number) =>
+2
View File
@@ -94,6 +94,7 @@ export interface MainIpcRuntimeServiceDepsParams {
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup']; openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
@@ -263,6 +264,7 @@ export function createMainIpcRuntimeServiceDeps(
openAnilistSetup: params.openAnilistSetup, openAnilistSetup: params.openAnilistSetup,
getAnilistQueueStatus: params.getAnilistQueueStatus, getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow, retryAnilistQueueNow: params.retryAnilistQueueNow,
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
getCharacterDictionarySelection: params.getCharacterDictionarySelection, getCharacterDictionarySelection: params.getCharacterDictionarySelection,
setCharacterDictionarySelection: params.setCharacterDictionarySelection, setCharacterDictionarySelection: params.setCharacterDictionarySelection,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
@@ -77,6 +77,50 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
assert.ok(calls.includes('inflight:false')); assert.ok(calls.includes('inflight:false'));
}); });
test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => false,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 0,
maybeProbeAnilistDuration: async () => {
calls.push('probe');
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler({ force: true });
assert.equal(calls.includes('probe'), false);
assert.ok(calls.includes('update'));
assert.ok(calls.includes('remember'));
assert.ok(calls.includes('osd:updated ok'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => { test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
const calls: string[] = []; const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({ const handler = createMaybeRunAnilistPostWatchUpdateHandler({
+9 -2
View File
@@ -16,6 +16,10 @@ type RetryQueueItem = {
episode: number; episode: number;
}; };
type AnilistPostWatchRunOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`; return `${mediaKey}::${episode}`;
} }
@@ -118,10 +122,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
minWatchSeconds: number; minWatchSeconds: number;
minWatchRatio: number; minWatchRatio: number;
}) { }) {
return async (): Promise<void> => { return async (options: AnilistPostWatchRunOptions = {}): Promise<void> => {
if (deps.getInFlight()) { if (deps.getInFlight()) {
return; return;
} }
const force = options.force === true;
const resolved = deps.getResolvedConfig(); const resolved = deps.getResolvedConfig();
if (!deps.isAnilistTrackingEnabled(resolved)) { if (!deps.isAnilistTrackingEnabled(resolved)) {
@@ -129,7 +134,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
} }
const mediaKey = deps.getCurrentMediaKey(); const mediaKey = deps.getCurrentMediaKey();
if (!mediaKey || !deps.hasMpvClient()) { if (!mediaKey || (!force && !deps.hasMpvClient())) {
return; return;
} }
if (isYoutubeMediaPath(mediaKey)) { if (isYoutubeMediaPath(mediaKey)) {
@@ -139,6 +144,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
deps.resetTrackedMedia(mediaKey); deps.resetTrackedMedia(mediaKey);
} }
if (!force) {
const watchedSeconds = deps.getWatchedSeconds(); const watchedSeconds = deps.getWatchedSeconds();
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
return; return;
@@ -151,6 +157,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
if (watchedSeconds / duration < deps.minWatchRatio) { if (watchedSeconds / duration < deps.minWatchRatio) {
return; return;
} }
}
const guess = await deps.ensureAnilistMediaGuess(mediaKey); const guess = await deps.ensureAnilistMediaGuess(mediaKey);
if (!guess?.title || !guess.episode || guess.episode <= 0) { if (!guess?.title || !guess.episode || guess.episode <= 0) {