diff --git a/backlog/tasks/task-326 - Fix-AniList-post-watch-update-after-skipped-completion-threshold.md b/backlog/tasks/task-326 - Fix-AniList-post-watch-update-after-skipped-completion-threshold.md index 1cf0b966..33487b28 100644 --- a/backlog/tasks/task-326 - Fix-AniList-post-watch-update-after-skipped-completion-threshold.md +++ b/backlog/tasks/task-326 - Fix-AniList-post-watch-update-after-skipped-completion-threshold.md @@ -19,8 +19,14 @@ AniList episode progress should sync reliably when playback reaches or passes th ## Acceptance Criteria -- [ ] #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. -- [ ] #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] #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] #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] #3 AniList progress updates remain deduplicated for the same media/episode watch completion. +- [x] #4 A regression test covers the skipped-threshold or already-past-threshold case. + +## 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. diff --git a/changes/326-anilist-time-position-post-watch.md b/changes/326-anilist-time-position-post-watch.md index 0971cd72..c504aadb 100644 --- a/changes/326-anilist-time-position-post-watch.md +++ b/changes/326-anilist-time-position-post-watch.md @@ -1,4 +1,4 @@ type: fixed 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. diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 3d9be47d..f598b8c7 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -302,6 +302,54 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { 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 () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: Array<[string, unknown[]]> = []; diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 690bda20..93db58f8 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -90,6 +90,7 @@ export interface IpcServiceDeps { openAnilistSetup: () => void; getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + runAnilistPostWatchUpdateOnManualMark?: () => Promise; getCharacterDictionarySelection?: () => Promise; setCharacterDictionarySelection?: (mediaId: number) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; @@ -213,6 +214,7 @@ export interface IpcDepsRuntimeOptions { openAnilistSetup: () => void; getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + runAnilistPostWatchUpdateOnManualMark?: () => Promise; getCharacterDictionarySelection?: () => Promise; setCharacterDictionarySelection?: (mediaId: number) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; @@ -288,6 +290,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService openAnilistSetup: options.openAnilistSetup, getAnilistQueueStatus: options.getAnilistQueueStatus, retryAnilistQueueNow: options.retryAnilistQueueNow, + runAnilistPostWatchUpdateOnManualMark: options.runAnilistPostWatchUpdateOnManualMark, getCharacterDictionarySelection: options.getCharacterDictionarySelection ?? (async () => ({ @@ -385,7 +388,11 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }); 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, () => { diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index f3c6a39b..b75485a3 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -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', () => { const parsed = splitMpvMessagesFromBuffer( '{"event":"shutdown"}\n{"event":"property-change","name":"media-title","data":"x"}\n{"partial"', diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index cb668601..3751a17e 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -276,8 +276,9 @@ export async function dispatchMpvProtocolMessage( deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null); deps.syncCurrentAudioStreamIndex(); } else if (msg.name === 'time-pos') { - deps.emitTimePosChange({ time: (msg.data as number) || 0 }); - deps.setCurrentTimePos((msg.data as number) || 0); + const timePos = (msg.data as number) || 0; + deps.setCurrentTimePos(timePos); + deps.emitTimePosChange({ time: timePos }); if ( deps.getPauseAtTime() !== null && deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number) diff --git a/src/main.ts b/src/main.ts index af9ac663..9ce74aa4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4947,6 +4947,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ openAnilistSetup: () => openAnilistSetupWindow(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), + runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }), getCharacterDictionarySelection: () => characterDictionaryRuntime.getManualSelectionSnapshot(), setCharacterDictionarySelection: async (mediaId: number) => diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 24672ea8..443019c4 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -94,6 +94,7 @@ export interface MainIpcRuntimeServiceDepsParams { openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; + runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark']; getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; @@ -263,6 +264,7 @@ export function createMainIpcRuntimeServiceDeps( openAnilistSetup: params.openAnilistSetup, getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, + runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark, getCharacterDictionarySelection: params.getCharacterDictionarySelection, setCharacterDictionarySelection: params.setCharacterDictionarySelection, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 94f2db6b..7ea23798 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -77,6 +77,50 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as 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 () => { const calls: string[] = []; const handler = createMaybeRunAnilistPostWatchUpdateHandler({ diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index 89bc3cc1..bc2fcd8d 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -16,6 +16,10 @@ type RetryQueueItem = { episode: number; }; +type AnilistPostWatchRunOptions = { + force?: boolean; +}; + export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { return `${mediaKey}::${episode}`; } @@ -118,10 +122,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { minWatchSeconds: number; minWatchRatio: number; }) { - return async (): Promise => { + return async (options: AnilistPostWatchRunOptions = {}): Promise => { if (deps.getInFlight()) { return; } + const force = options.force === true; const resolved = deps.getResolvedConfig(); if (!deps.isAnilistTrackingEnabled(resolved)) { @@ -129,7 +134,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { } const mediaKey = deps.getCurrentMediaKey(); - if (!mediaKey || !deps.hasMpvClient()) { + if (!mediaKey || (!force && !deps.hasMpvClient())) { return; } if (isYoutubeMediaPath(mediaKey)) { @@ -139,17 +144,19 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { deps.resetTrackedMedia(mediaKey); } - const watchedSeconds = deps.getWatchedSeconds(); - if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { - return; - } + if (!force) { + const watchedSeconds = deps.getWatchedSeconds(); + if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { + return; + } - const duration = await deps.maybeProbeAnilistDuration(mediaKey); - if (!duration || duration <= 0) { - return; - } - if (watchedSeconds / duration < deps.minWatchRatio) { - return; + const duration = await deps.maybeProbeAnilistDuration(mediaKey); + if (!duration || duration <= 0) { + return; + } + if (watchedSeconds / duration < deps.minWatchRatio) { + return; + } } const guess = await deps.ensureAnilistMediaGuess(mediaKey);