mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
fix: sync AniList after seeked completion
This commit is contained in:
+10
-4
@@ -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.
|
||||||
|
|||||||
@@ -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[]]> = [];
|
||||||
|
|||||||
@@ -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, () => {
|
||||||
|
|||||||
@@ -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"',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,17 +144,19 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
deps.resetTrackedMedia(mediaKey);
|
deps.resetTrackedMedia(mediaKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const watchedSeconds = deps.getWatchedSeconds();
|
if (!force) {
|
||||||
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
const watchedSeconds = deps.getWatchedSeconds();
|
||||||
return;
|
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
||||||
if (!duration || duration <= 0) {
|
if (!duration || duration <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (watchedSeconds / duration < deps.minWatchRatio) {
|
if (watchedSeconds / duration < deps.minWatchRatio) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
|
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
|
||||||
|
|||||||
Reference in New Issue
Block a user