mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
feat(aniskip): move intro detection from mpv plugin to app runtime (#117)
This commit is contained in:
@@ -496,13 +496,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
path: 'mpv.aniskipEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.aniskipEnabled,
|
||||
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
|
||||
description: 'Enable AniSkip intro detection, chapter markers, and the skip-intro key.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.aniskipButtonKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
|
||||
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
|
||||
@@ -680,6 +680,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.fields.miscInfo' ||
|
||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||
path === 'mpv.aniskipEnabled' ||
|
||||
path === 'mpv.aniskipButtonKey' ||
|
||||
path === 'stats.toggleKey' ||
|
||||
path === 'stats.markWatchedKey' ||
|
||||
|
||||
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'ankiConnect.ai.enabled',
|
||||
'stats.toggleKey',
|
||||
|
||||
@@ -350,3 +350,21 @@ test('visibility and boolean parsers handle text values', () => {
|
||||
assert.equal(asBoolean('yes', false), true);
|
||||
assert.equal(asBoolean('0', true), false);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits client-message string args', async () => {
|
||||
const received: Array<{ args: string[] }> = [];
|
||||
const { deps } = createDeps({
|
||||
emitClientMessage: (payload) => {
|
||||
received.push(payload);
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'client-message', args: ['subminer-skip-intro', 42, 'extra'] },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'client-message', args: [] }, deps);
|
||||
await dispatchMpvProtocolMessage({ event: 'client-message' }, deps);
|
||||
|
||||
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export type MpvMessage = {
|
||||
event?: string;
|
||||
name?: string;
|
||||
data?: unknown;
|
||||
args?: unknown;
|
||||
request_id?: number;
|
||||
error?: string;
|
||||
};
|
||||
@@ -94,6 +95,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
shouldQuitOnMpvShutdown: () => boolean;
|
||||
requestAppQuit: () => void;
|
||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -376,6 +378,13 @@ export async function dispatchMpvProtocolMessage(
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.event === 'client-message') {
|
||||
const args = Array.isArray(msg.args)
|
||||
? msg.args.filter((arg): arg is string => typeof arg === 'string')
|
||||
: [];
|
||||
if (args.length > 0) {
|
||||
deps.emitClientMessage?.({ args });
|
||||
}
|
||||
} else if (msg.event === 'shutdown') {
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
if (deps.shouldQuitOnMpvShutdown()) {
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface MpvIpcClientEventMap {
|
||||
'media-title-change': { title: string | null };
|
||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||
'secondary-subtitle-visibility': { visible: boolean };
|
||||
'client-message': { args: string[] };
|
||||
}
|
||||
|
||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||
@@ -491,6 +492,9 @@ export class MpvIpcClient implements MpvClient {
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => this.deps.requestAppQuit?.(),
|
||||
emitClientMessage: (payload) => {
|
||||
this.emit('client-message', payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ export function buildWindowsMpvPluginRuntimeConfig(
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -326,8 +326,6 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script
|
||||
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
|
||||
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||
aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
@@ -359,8 +357,6 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
subminerBinaryPath: 'C:\\SubMiner\\Custom.exe',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -382,8 +378,6 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
+34
-2
@@ -33,6 +33,8 @@ import {
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import {
|
||||
@@ -1468,8 +1470,6 @@ function getMpvPluginRuntimeConfig() {
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2231,6 +2231,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
setLogFileToggles: (files) => {
|
||||
setLogFileToggles(files);
|
||||
},
|
||||
applyAniSkipConfig: () => {
|
||||
aniSkipRuntime.applyConfigChange();
|
||||
},
|
||||
},
|
||||
);
|
||||
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
|
||||
@@ -5401,6 +5404,31 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease(
|
||||
});
|
||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||
|
||||
const aniSkipRuntime = createAniSkipRuntime({
|
||||
getAniSkipConfig: () => ({
|
||||
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
|
||||
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
|
||||
}),
|
||||
resolveMetadataForFile: (mediaPath) => resolveAniSkipMetadataForFile(mediaPath),
|
||||
sendMpvCommand: (command) => {
|
||||
appState.mpvClient?.send({ command });
|
||||
},
|
||||
requestMpvProperty: (name) => {
|
||||
if (!appState.mpvClient) {
|
||||
return Promise.reject(new Error('MPV not connected'));
|
||||
}
|
||||
return appState.mpvClient.requestProperty(name);
|
||||
},
|
||||
isMpvConnected: () => appState.mpvClient?.connected === true,
|
||||
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
showMpvOsd: (text, durationMs) => {
|
||||
appState.mpvClient?.send({ command: ['show-text', text, durationMs] });
|
||||
},
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
@@ -5414,6 +5442,10 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
||||
overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
|
||||
});
|
||||
client.on('connection-change', aniSkipRuntime.handleConnectionChange);
|
||||
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
|
||||
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
|
||||
client.on('client-message', aniSkipRuntime.handleClientMessage);
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
inferAniSkipMetadataForFile,
|
||||
parseAniSkipGuessitJson,
|
||||
resolveAniSkipMetadataForFile,
|
||||
} from './aniskip-metadata';
|
||||
|
||||
function makeMockResponse(payload: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => payload,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function normalizeFetchInput(input: string | URL | Request): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
return input.url;
|
||||
}
|
||||
|
||||
async function withMockFetch(
|
||||
handler: (input: string | URL | Request) => Promise<Response>,
|
||||
fn: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const original = globalThis.fetch;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = (async (input: string | URL | Request) => {
|
||||
return handler(input);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(globalThis as { fetch: typeof fetch }).fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
||||
const parsed = parseAniSkipGuessitJson(
|
||||
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
||||
'/tmp/My.Show.S02E07.mkv',
|
||||
);
|
||||
assert.deepEqual(parsed, {
|
||||
title: 'My Show',
|
||||
season: 2,
|
||||
episode: 7,
|
||||
source: 'guessit',
|
||||
malId: null,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'lookup_failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAniSkipGuessitJson prefers series over episode title', () => {
|
||||
const parsed = parseAniSkipGuessitJson(
|
||||
JSON.stringify({
|
||||
title: 'What Is This, a Picnic',
|
||||
series: 'Solo Leveling',
|
||||
season: 1,
|
||||
episode: 10,
|
||||
}),
|
||||
'/tmp/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv',
|
||||
);
|
||||
assert.deepEqual(parsed, {
|
||||
title: 'Solo Leveling',
|
||||
season: 1,
|
||||
episode: 10,
|
||||
source: 'guessit',
|
||||
malId: null,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'lookup_failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('inferAniSkipMetadataForFile falls back to filename title when guessit unavailable', () => {
|
||||
const parsed = inferAniSkipMetadataForFile('/tmp/Another_Show_-_03.mkv', {
|
||||
commandExists: () => false,
|
||||
runGuessit: () => null,
|
||||
});
|
||||
assert.equal(parsed.title.length > 0, true);
|
||||
assert.equal(parsed.source, 'fallback');
|
||||
});
|
||||
|
||||
test('inferAniSkipMetadataForFile falls back to anime directory title when filename is episode-only', () => {
|
||||
const parsed = inferAniSkipMetadataForFile(
|
||||
'/truenas/jellyfin/anime/Solo Leveling/Season-1/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv',
|
||||
{
|
||||
commandExists: () => false,
|
||||
runGuessit: () => null,
|
||||
},
|
||||
);
|
||||
assert.equal(parsed.title, 'Solo Leveling');
|
||||
assert.equal(parsed.season, 1);
|
||||
assert.equal(parsed.episode, 10);
|
||||
assert.equal(parsed.source, 'fallback');
|
||||
});
|
||||
|
||||
test('inferAniSkipMetadataForFile handles release-group filename with trailing quality tags', () => {
|
||||
const parsed = inferAniSkipMetadataForFile(
|
||||
'/media/anime/Solo Leveling/Season 1/[SubsPlease] Solo Leveling - 01 (1080p) [ABCDEF12].mkv',
|
||||
{
|
||||
commandExists: () => false,
|
||||
runGuessit: () => null,
|
||||
},
|
||||
);
|
||||
assert.equal(parsed.title, 'Solo Leveling');
|
||||
assert.equal(parsed.season, 1);
|
||||
assert.equal(parsed.episode, 1);
|
||||
assert.equal(parsed.source, 'fallback');
|
||||
});
|
||||
|
||||
test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
|
||||
await withMockFetch(
|
||||
async (input) => {
|
||||
const url = normalizeFetchInput(input);
|
||||
if (url.includes('myanimelist.net/search/prefix.json')) {
|
||||
return makeMockResponse({
|
||||
categories: [
|
||||
{
|
||||
items: [
|
||||
{ id: '9876', name: 'Wrong Match' },
|
||||
{ id: '1234', name: 'My Show' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.includes('api.aniskip.com/v1/skip-times/1234/7')) {
|
||||
return makeMockResponse({
|
||||
found: true,
|
||||
results: [{ skip_type: 'op', interval: { start_time: 12.5, end_time: 54.2 } }],
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
},
|
||||
async () => {
|
||||
const resolved = await resolveAniSkipMetadataForFile('/media/Anime.My.Show.S01E07.mkv');
|
||||
assert.equal(resolved.malId, 1234);
|
||||
assert.equal(resolved.introStart, 12.5);
|
||||
assert.equal(resolved.introEnd, 54.2);
|
||||
assert.equal(resolved.lookupStatus, 'ready');
|
||||
assert.equal(resolved.title, 'Anime My Show');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAniSkipMetadataForFile accepts intro payload starting at zero', async () => {
|
||||
await withMockFetch(
|
||||
async (input) => {
|
||||
const url = normalizeFetchInput(input);
|
||||
if (url.includes('myanimelist.net/search/prefix.json')) {
|
||||
return makeMockResponse({
|
||||
categories: [{ items: [{ id: '1234', name: 'My Show' }] }],
|
||||
});
|
||||
}
|
||||
if (url.includes('api.aniskip.com/v1/skip-times/1234/1')) {
|
||||
return makeMockResponse({
|
||||
found: true,
|
||||
results: [{ skip_type: 'op', interval: { start_time: 0, end_time: 89.5 } }],
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
},
|
||||
async () => {
|
||||
const resolved = await resolveAniSkipMetadataForFile('/media/My.Show.S01E01.mkv');
|
||||
assert.equal(resolved.malId, 1234);
|
||||
assert.equal(resolved.introStart, 0);
|
||||
assert.equal(resolved.introEnd, 89.5);
|
||||
assert.equal(resolved.lookupStatus, 'ready');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
|
||||
await withMockFetch(
|
||||
async () => makeMockResponse({ categories: [] }),
|
||||
async () => {
|
||||
const resolved = await resolveAniSkipMetadataForFile('/media/NopeShow.S01E03.mkv');
|
||||
assert.equal(resolved.malId, null);
|
||||
assert.equal(resolved.lookupStatus, 'missing_mal_id');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export type AniSkipLookupStatus =
|
||||
| 'ready'
|
||||
| 'missing_mal_id'
|
||||
| 'missing_episode'
|
||||
| 'missing_payload'
|
||||
| 'lookup_failed';
|
||||
|
||||
export interface AniSkipMetadata {
|
||||
title: string;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
source: 'guessit' | 'fallback';
|
||||
malId: number | null;
|
||||
introStart: number | null;
|
||||
introEnd: number | null;
|
||||
lookupStatus?: AniSkipLookupStatus;
|
||||
}
|
||||
|
||||
interface InferAniSkipDeps {
|
||||
commandExists: (name: string) => boolean;
|
||||
runGuessit: (mediaPath: string) => string | null;
|
||||
}
|
||||
|
||||
interface MalSearchResult {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
}
|
||||
|
||||
interface MalSearchCategory {
|
||||
items?: unknown;
|
||||
}
|
||||
|
||||
interface MalSearchResponse {
|
||||
categories?: unknown;
|
||||
}
|
||||
|
||||
interface AniSkipIntervalPayload {
|
||||
start_time?: unknown;
|
||||
end_time?: unknown;
|
||||
}
|
||||
|
||||
interface AniSkipSkipItemPayload {
|
||||
skip_type?: unknown;
|
||||
interval?: unknown;
|
||||
}
|
||||
|
||||
interface AniSkipPayloadResponse {
|
||||
found?: unknown;
|
||||
results?: unknown;
|
||||
}
|
||||
|
||||
const ROMAN_SEASON_ALIASES: Record<number, readonly string[]> = {
|
||||
2: [' ii ', ' second season ', ' 2nd season '],
|
||||
3: [' iii ', ' third season ', ' 3rd season '],
|
||||
4: [' iv ', ' fourth season ', ' 4th season '],
|
||||
5: [' v ', ' fifth season ', ' 5th season '],
|
||||
};
|
||||
|
||||
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
||||
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
|
||||
const ANISKIP_USER_AGENT = 'SubMiner/ani-skip';
|
||||
const ANISKIP_FETCH_TIMEOUT_MS = 8000;
|
||||
const MAL_MATCH_STOPWORDS = new Set([
|
||||
'the',
|
||||
'this',
|
||||
'that',
|
||||
'world',
|
||||
'animated',
|
||||
'series',
|
||||
'season',
|
||||
'no',
|
||||
'on',
|
||||
'and',
|
||||
]);
|
||||
|
||||
function commandExistsOnPath(command: string): boolean {
|
||||
const pathEnv = process.env.PATH || '';
|
||||
const extensions =
|
||||
process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : [''];
|
||||
for (const dir of pathEnv.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const extension of extensions) {
|
||||
const candidate = path.join(dir, `${command}${extension.toLowerCase()}`);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// keep scanning PATH entries
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toPositiveNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toNonNegativeNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeForMatch(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^\w]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tokenizeMatchWords(value: string): string[] {
|
||||
const words = normalizeForMatch(value)
|
||||
.split(' ')
|
||||
.filter((word) => word.length >= 3);
|
||||
return words.filter((word) => !MAL_MATCH_STOPWORDS.has(word));
|
||||
}
|
||||
|
||||
function titleOverlapScore(expectedTitle: string, candidateTitle: string): number {
|
||||
const expected = normalizeForMatch(expectedTitle);
|
||||
const candidate = normalizeForMatch(candidateTitle);
|
||||
|
||||
if (!expected || !candidate) return 0;
|
||||
|
||||
if (candidate.includes(expected)) return 120;
|
||||
if (candidate.split(' ').length >= 2 && ` ${expected} `.includes(` ${candidate} `)) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
||||
if (expectedTokens.length === 0) return 0;
|
||||
|
||||
const candidateSet = new Set(tokenizeMatchWords(candidateTitle));
|
||||
let score = 0;
|
||||
let matched = 0;
|
||||
|
||||
for (const token of expectedTokens) {
|
||||
if (candidateSet.has(token)) {
|
||||
score += 30;
|
||||
matched += 1;
|
||||
} else {
|
||||
score -= 20;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched === 0) {
|
||||
score -= 80;
|
||||
}
|
||||
|
||||
const coverage = matched / expectedTokens.length;
|
||||
if (expectedTokens.length >= 2) {
|
||||
if (coverage >= 0.8) score += 30;
|
||||
else if (coverage >= 0.6) score += 10;
|
||||
else score -= 50;
|
||||
} else if (coverage >= 1) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function hasAnySequelMarker(candidateTitle: string): boolean {
|
||||
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
||||
if (!normalized.trim()) return false;
|
||||
|
||||
const markers = [
|
||||
'season 2',
|
||||
'season 3',
|
||||
'season 4',
|
||||
'2nd season',
|
||||
'3rd season',
|
||||
'4th season',
|
||||
'second season',
|
||||
'third season',
|
||||
'fourth season',
|
||||
' ii ',
|
||||
' iii ',
|
||||
' iv ',
|
||||
];
|
||||
return markers.some((marker) => normalized.includes(marker));
|
||||
}
|
||||
|
||||
function seasonSignalScore(requestedSeason: number | null, candidateTitle: string): number {
|
||||
const season = toPositiveInt(requestedSeason);
|
||||
if (!season || season < 1) return 0;
|
||||
|
||||
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
||||
if (!normalized.trim()) return 0;
|
||||
|
||||
if (season === 1) {
|
||||
return hasAnySequelMarker(candidateTitle) ? -60 : 20;
|
||||
}
|
||||
|
||||
const numericMarker = ` season ${season} `;
|
||||
const ordinalMarker = ` ${season}th season `;
|
||||
if (normalized.includes(numericMarker) || normalized.includes(ordinalMarker)) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
const aliases = ROMAN_SEASON_ALIASES[season] ?? [];
|
||||
return aliases.some((alias) => normalized.includes(alias))
|
||||
? 40
|
||||
: hasAnySequelMarker(candidateTitle)
|
||||
? -20
|
||||
: 5;
|
||||
}
|
||||
|
||||
function toMalSearchItems(payload: unknown): MalSearchResult[] {
|
||||
const parsed = payload as MalSearchResponse;
|
||||
const categories = Array.isArray(parsed?.categories) ? parsed.categories : null;
|
||||
if (!categories) return [];
|
||||
|
||||
const items: MalSearchResult[] = [];
|
||||
for (const category of categories) {
|
||||
const typedCategory = category as MalSearchCategory;
|
||||
const rawItems = Array.isArray(typedCategory?.items) ? typedCategory.items : [];
|
||||
for (const rawItem of rawItems) {
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
items.push({
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
|
||||
const parsed = payload as AniSkipPayloadResponse;
|
||||
const results = Array.isArray(parsed?.results) ? parsed.results : null;
|
||||
if (!results) return null;
|
||||
|
||||
for (const rawResult of results) {
|
||||
const result = rawResult as AniSkipSkipItemPayload;
|
||||
if (
|
||||
result.skip_type !== 'op' ||
|
||||
typeof result.interval !== 'object' ||
|
||||
result.interval === null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const interval = result.interval as AniSkipIntervalPayload;
|
||||
const start = toNonNegativeNumber(interval?.start_time);
|
||||
const end = toPositiveNumber(interval?.end_time);
|
||||
if (start !== null && end !== null && end > start) {
|
||||
return { start, end };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': ANISKIP_USER_AGENT,
|
||||
},
|
||||
signal: AbortSignal.timeout(ANISKIP_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMalIdFromTitle(title: string, season: number | null): Promise<number | null> {
|
||||
const lookup = season && season > 1 ? `${title} Season ${season}` : title;
|
||||
const payload = await fetchJson<unknown>(`${MAL_PREFIX_API}${encodeURIComponent(lookup)}`);
|
||||
const items = toMalSearchItems(payload);
|
||||
if (!items.length) return null;
|
||||
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
let bestMalId: number | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
const id = toPositiveInt(item.id);
|
||||
if (!id) continue;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
if (!name) continue;
|
||||
|
||||
const score = titleOverlapScore(title, name) + seasonSignalScore(season, name);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMalId = id;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMalId;
|
||||
}
|
||||
|
||||
async function fetchAniSkipPayload(
|
||||
malId: number,
|
||||
episode: number,
|
||||
): Promise<{ start: number; end: number } | null> {
|
||||
const payload = await fetchJson<unknown>(
|
||||
`${ANISKIP_PAYLOAD_API}${malId}/${episode}?types=op&types=ed`,
|
||||
);
|
||||
const parsed = payload as AniSkipPayloadResponse;
|
||||
if (!parsed || parsed.found !== true) return null;
|
||||
return parseAniSkipPayload(parsed);
|
||||
}
|
||||
|
||||
function detectEpisodeFromName(baseName: string): number | null {
|
||||
const patterns = [
|
||||
/[Ss]\d+[Ee](\d{1,3})/,
|
||||
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
|
||||
/(?:^|[\s._-])(\d{1,3})(?:$|[\s._-])/,
|
||||
/[-\s](\d{1,3})$/,
|
||||
];
|
||||
const groupStrippedName = baseName.replace(/\[[^\]]+\]/g, ' ').replace(/\([^)]+\)/g, ' ');
|
||||
for (const candidate of [baseName, groupStrippedName]) {
|
||||
for (const pattern of patterns) {
|
||||
const match = candidate.match(pattern);
|
||||
if (!match || !match[1]) continue;
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectSeasonFromNameOrDir(mediaPath: string): number | null {
|
||||
const baseName = path.basename(mediaPath, path.extname(mediaPath));
|
||||
const seasonMatch = baseName.match(/[Ss](\d{1,2})[Ee]\d{1,3}/);
|
||||
if (seasonMatch && seasonMatch[1]) {
|
||||
const parsed = Number.parseInt(seasonMatch[1], 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
const parent = path.basename(path.dirname(mediaPath));
|
||||
const parentMatch = parent.match(/(?:Season|S)[\s._-]*(\d{1,2})/i);
|
||||
if (parentMatch && parentMatch[1]) {
|
||||
const parsed = Number.parseInt(parentMatch[1], 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSeasonDirectoryName(value: string): boolean {
|
||||
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
|
||||
}
|
||||
|
||||
function isEpisodeOnlyBaseName(value: string): boolean {
|
||||
return /^(?:[Ss]\d{1,2}[Ee]\d{1,3}|[Ee][Pp]?[\s._-]*\d{1,3}|\d{1,3})(?:$|[\s._-])/.test(
|
||||
value.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function inferTitleFromPath(mediaPath: string): string {
|
||||
const directory = path.dirname(mediaPath);
|
||||
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index] || '';
|
||||
if (!isSeasonDirectoryName(segment)) continue;
|
||||
const showSegment = segments[index - 1];
|
||||
if (typeof showSegment === 'string' && showSegment.length > 0) {
|
||||
const cleaned = cleanupTitle(showSegment);
|
||||
if (cleaned) return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
const parent = path.basename(directory);
|
||||
if (!isSeasonDirectoryName(parent)) {
|
||||
const cleanedParent = cleanupTitle(parent);
|
||||
if (cleanedParent) return cleanedParent;
|
||||
}
|
||||
|
||||
const grandParent = path.basename(path.dirname(directory));
|
||||
const cleanedGrandParent = cleanupTitle(grandParent);
|
||||
return cleanedGrandParent;
|
||||
}
|
||||
|
||||
function cleanupTitle(value: string): string {
|
||||
return value
|
||||
.replace(/\.[^/.]+$/, '')
|
||||
.replace(/\[[^\]]+\]/g, ' ')
|
||||
.replace(/\([^)]+\)/g, ' ')
|
||||
.replace(/[Ss]\d+[Ee]\d+/g, ' ')
|
||||
.replace(/[Ee][Pp]?[\s._-]*\d+/g, ' ')
|
||||
.replace(/(?:^|[\s._-])\d{1,3}\s*$/g, ' ')
|
||||
.replace(/[_\-.]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniSkipMetadata | null {
|
||||
const payload = stdout.trim();
|
||||
if (!payload) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as {
|
||||
title?: unknown;
|
||||
title_original?: unknown;
|
||||
series?: unknown;
|
||||
season?: unknown;
|
||||
episode?: unknown;
|
||||
episode_list?: unknown;
|
||||
};
|
||||
|
||||
const rawTitle =
|
||||
(typeof parsed.series === 'string' && parsed.series) ||
|
||||
(typeof parsed.title === 'string' && parsed.title) ||
|
||||
(typeof parsed.title_original === 'string' && parsed.title_original) ||
|
||||
'';
|
||||
|
||||
const title = cleanupTitle(rawTitle) || inferTitleFromPath(mediaPath);
|
||||
if (!title) return null;
|
||||
|
||||
const season = toPositiveInt(parsed.season);
|
||||
const episodeFromDirect = toPositiveInt(parsed.episode);
|
||||
const episodeFromList =
|
||||
Array.isArray(parsed.episode_list) && parsed.episode_list.length > 0
|
||||
? toPositiveInt(parsed.episode_list[0])
|
||||
: null;
|
||||
|
||||
return {
|
||||
title,
|
||||
season,
|
||||
episode: episodeFromDirect ?? episodeFromList,
|
||||
source: 'guessit',
|
||||
malId: null,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'lookup_failed',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRunGuessit(mediaPath: string): string | null {
|
||||
const fileName = path.basename(mediaPath);
|
||||
const result = spawnSync('guessit', ['--json', fileName], {
|
||||
cwd: path.dirname(mediaPath),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 2_000_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return result.stdout || null;
|
||||
}
|
||||
|
||||
export function inferAniSkipMetadataForFile(
|
||||
mediaPath: string,
|
||||
deps: InferAniSkipDeps = { commandExists: commandExistsOnPath, runGuessit: defaultRunGuessit },
|
||||
): AniSkipMetadata {
|
||||
if (deps.commandExists('guessit')) {
|
||||
const stdout = deps.runGuessit(mediaPath);
|
||||
if (typeof stdout === 'string') {
|
||||
const parsed = parseAniSkipGuessitJson(stdout, mediaPath);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = path.basename(mediaPath, path.extname(mediaPath));
|
||||
const cleanedBaseName = cleanupTitle(baseName);
|
||||
const pathTitle = inferTitleFromPath(mediaPath);
|
||||
const fallbackTitle = isEpisodeOnlyBaseName(baseName)
|
||||
? pathTitle || cleanedBaseName || baseName
|
||||
: cleanedBaseName || pathTitle || baseName;
|
||||
return {
|
||||
title: fallbackTitle,
|
||||
season: detectSeasonFromNameOrDir(mediaPath),
|
||||
episode: detectEpisodeFromName(baseName),
|
||||
source: 'fallback',
|
||||
malId: null,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'lookup_failed',
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<AniSkipMetadata> {
|
||||
const inferred = inferAniSkipMetadataForFile(mediaPath);
|
||||
if (!inferred.title) {
|
||||
return { ...inferred, lookupStatus: 'lookup_failed' };
|
||||
}
|
||||
|
||||
try {
|
||||
const malId = await resolveMalIdFromTitle(inferred.title, inferred.season);
|
||||
if (!malId) {
|
||||
return {
|
||||
...inferred,
|
||||
malId: null,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'missing_mal_id',
|
||||
};
|
||||
}
|
||||
|
||||
if (!inferred.episode) {
|
||||
return {
|
||||
...inferred,
|
||||
malId,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'missing_episode',
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await fetchAniSkipPayload(malId, inferred.episode);
|
||||
if (!payload) {
|
||||
return {
|
||||
...inferred,
|
||||
malId,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'missing_payload',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...inferred,
|
||||
malId,
|
||||
introStart: payload.start,
|
||||
introEnd: payload.end,
|
||||
lookupStatus: 'ready',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...inferred,
|
||||
malId: inferred.malId,
|
||||
introStart: inferred.introStart,
|
||||
introEnd: inferred.introEnd,
|
||||
lookupStatus: 'lookup_failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAniSkipRuntime, isRemoteMediaPath, AniSkipRuntimeDeps } from './aniskip-runtime';
|
||||
import type { AniSkipMetadata } from './aniskip-metadata';
|
||||
|
||||
function readyMetadata(overrides: Partial<AniSkipMetadata> = {}): AniSkipMetadata {
|
||||
return {
|
||||
title: 'My Show',
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'fallback',
|
||||
malId: 1234,
|
||||
introStart: 10,
|
||||
introEnd: 95.5,
|
||||
lookupStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(options?: {
|
||||
enabled?: boolean;
|
||||
buttonKey?: string;
|
||||
metadata?: AniSkipMetadata | (() => Promise<AniSkipMetadata>);
|
||||
chapterList?: unknown;
|
||||
}) {
|
||||
const state = {
|
||||
enabled: options?.enabled ?? true,
|
||||
buttonKey: options?.buttonKey ?? 'TAB',
|
||||
commands: [] as unknown[][],
|
||||
osd: [] as string[],
|
||||
resolveCalls: [] as string[],
|
||||
connected: true,
|
||||
timePos: 0,
|
||||
chapterList: options?.chapterList ?? [],
|
||||
};
|
||||
|
||||
const deps: AniSkipRuntimeDeps = {
|
||||
getAniSkipConfig: () => ({
|
||||
aniskipEnabled: state.enabled,
|
||||
aniskipButtonKey: state.buttonKey,
|
||||
}),
|
||||
resolveMetadataForFile: async (mediaPath) => {
|
||||
state.resolveCalls.push(mediaPath);
|
||||
const metadata = options?.metadata;
|
||||
if (typeof metadata === 'function') return metadata();
|
||||
return metadata ?? readyMetadata();
|
||||
},
|
||||
sendMpvCommand: (command) => {
|
||||
state.commands.push(command);
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'chapter-list') return state.chapterList;
|
||||
return null;
|
||||
},
|
||||
isMpvConnected: () => state.connected,
|
||||
getCurrentTimePos: () => state.timePos,
|
||||
showMpvOsd: (text) => {
|
||||
state.osd.push(text);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logDebug: () => {},
|
||||
};
|
||||
|
||||
return { runtime: createAniSkipRuntime(deps), state };
|
||||
}
|
||||
|
||||
function chapterListCommands(commands: unknown[][]): unknown[][] {
|
||||
return commands.filter(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'chapter-list',
|
||||
);
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('isRemoteMediaPath detects URLs but not local paths', () => {
|
||||
assert.equal(isRemoteMediaPath('https://example.com/stream.mkv'), true);
|
||||
assert.equal(isRemoteMediaPath('rtmp://example.com/live'), true);
|
||||
assert.equal(isRemoteMediaPath('/media/anime/show.mkv'), false);
|
||||
assert.equal(isRemoteMediaPath('C:\\media\\show.mkv'), false);
|
||||
assert.equal(isRemoteMediaPath(''), false);
|
||||
});
|
||||
|
||||
test('media path change resolves metadata and sets AniSkip chapters', async () => {
|
||||
const { runtime, state } = createHarness({
|
||||
chapterList: [{ time: 0, title: 'Prologue' }],
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange({ path: '/media/anime/My Show/ep1.mkv' });
|
||||
await flushAsync();
|
||||
|
||||
assert.deepEqual(state.resolveCalls, ['/media/anime/My Show/ep1.mkv']);
|
||||
const chapterCommands = chapterListCommands(state.commands);
|
||||
assert.equal(chapterCommands.length, 1);
|
||||
const chapters = chapterCommands[0]![2] as Array<{ time: number; title: string }>;
|
||||
assert.deepEqual(chapters, [
|
||||
{ time: 0, title: 'Prologue' },
|
||||
{ time: 10, title: 'AniSkip Intro Start' },
|
||||
{ time: 95.5, title: 'AniSkip Intro End' },
|
||||
]);
|
||||
assert.deepEqual(runtime.getIntroWindow(), { start: 10, end: 95.5, malId: 1234 });
|
||||
});
|
||||
|
||||
test('remote media paths and disabled config never resolve', async () => {
|
||||
const remote = createHarness();
|
||||
remote.runtime.handleMediaPathChange({ path: 'https://example.com/video.mkv' });
|
||||
await flushAsync();
|
||||
assert.deepEqual(remote.state.resolveCalls, []);
|
||||
|
||||
const disabled = createHarness({ enabled: false });
|
||||
disabled.runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.deepEqual(disabled.state.resolveCalls, []);
|
||||
});
|
||||
|
||||
test('skip intro seeks to intro end only inside the intro window', async () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
|
||||
state.timePos = 200;
|
||||
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||
assert.deepEqual(state.osd, ['Skip intro only during intro']);
|
||||
|
||||
state.timePos = 30;
|
||||
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||
assert.deepEqual(state.osd, ['Skip intro only during intro', 'Skipped intro']);
|
||||
const seek = state.commands.find(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'time-pos',
|
||||
);
|
||||
assert.deepEqual(seek, ['set_property', 'time-pos', 95.5]);
|
||||
});
|
||||
|
||||
test('skip intro reports unavailable when no window was found', () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||
assert.deepEqual(state.osd, ['Intro skip unavailable']);
|
||||
});
|
||||
|
||||
test('time-pos prompt shows once near intro start', async () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
|
||||
runtime.handleTimePosChange({ time: 5 });
|
||||
assert.deepEqual(state.osd, []);
|
||||
|
||||
runtime.handleTimePosChange({ time: 10.5 });
|
||||
runtime.handleTimePosChange({ time: 11 });
|
||||
assert.deepEqual(state.osd, ['You can skip by pressing TAB']);
|
||||
});
|
||||
|
||||
test('connection change binds skip key and legacy fallback for custom keys', () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'F6' });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
assert.deepEqual(state.commands, [
|
||||
['keybind', 'F6', 'script-message subminer-skip-intro'],
|
||||
['keybind', 'y-k', 'script-message subminer-skip-intro'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('default key binds without duplicate legacy fallback', () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
assert.deepEqual(state.commands, [['keybind', 'TAB', 'script-message subminer-skip-intro']]);
|
||||
});
|
||||
|
||||
test('config change rebinds key and disabling unbinds and clears chapters', async () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
|
||||
state.buttonKey = 'F6';
|
||||
runtime.applyConfigChange();
|
||||
assert.deepEqual(state.commands.slice(1), [
|
||||
['keybind', 'TAB', ''],
|
||||
['keybind', 'F6', 'script-message subminer-skip-intro'],
|
||||
['keybind', 'y-k', 'script-message subminer-skip-intro'],
|
||||
]);
|
||||
|
||||
state.commands.length = 0;
|
||||
state.enabled = false;
|
||||
state.chapterList = [
|
||||
{ time: 0, title: 'Prologue' },
|
||||
{ time: 10, title: 'AniSkip Intro Start' },
|
||||
{ time: 95.5, title: 'AniSkip Intro End' },
|
||||
];
|
||||
runtime.applyConfigChange();
|
||||
await flushAsync();
|
||||
assert.deepEqual(state.commands, [
|
||||
['keybind', 'F6', ''],
|
||||
['keybind', 'y-k', ''],
|
||||
['set_property', 'chapter-list', [{ time: 0, title: 'Prologue' }]],
|
||||
]);
|
||||
});
|
||||
|
||||
test('same-media reload re-applies chapters without a new lookup', async () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 1);
|
||||
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 1);
|
||||
assert.equal(chapterListCommands(state.commands).length, 2);
|
||||
});
|
||||
|
||||
test('aniskip refresh forces a fresh lookup for the current media', async () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 1);
|
||||
|
||||
runtime.handleClientMessage({ args: ['subminer-aniskip-refresh'] });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 2);
|
||||
});
|
||||
|
||||
test('media without an intro window is cached and never re-resolved on reload of another file', async () => {
|
||||
const { runtime, state } = createHarness({
|
||||
metadata: readyMetadata({
|
||||
malId: 1234,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'missing_payload',
|
||||
}),
|
||||
});
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(runtime.getIntroWindow(), null);
|
||||
assert.equal(chapterListCommands(state.commands).length, 0);
|
||||
|
||||
runtime.handleMediaPathChange({ path: '/media/other.mkv' });
|
||||
await flushAsync();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.deepEqual(state.resolveCalls, ['/media/show.mkv', '/media/other.mkv']);
|
||||
});
|
||||
|
||||
test('transient lookup failures are retried on the next media load', async () => {
|
||||
let failures = 0;
|
||||
const { runtime, state } = createHarness({
|
||||
metadata: async () => {
|
||||
failures += 1;
|
||||
return readyMetadata(
|
||||
failures === 1 ? { introStart: null, introEnd: null, lookupStatus: 'lookup_failed' } : {},
|
||||
);
|
||||
},
|
||||
});
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(runtime.getIntroWindow(), null);
|
||||
|
||||
runtime.handleMediaPathChange({ path: '' });
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 2);
|
||||
assert.deepEqual(runtime.getIntroWindow(), { start: 10, end: 95.5, malId: 1234 });
|
||||
});
|
||||
|
||||
test('disconnect clears bindings so reconnect rebinds the skip key', () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
runtime.handleConnectionChange({ connected: false });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
assert.deepEqual(state.commands, [
|
||||
['keybind', 'TAB', 'script-message subminer-skip-intro'],
|
||||
['keybind', 'TAB', 'script-message subminer-skip-intro'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { AniSkipMetadata } from './aniskip-metadata';
|
||||
|
||||
export const ANISKIP_SKIP_INTRO_MESSAGE = 'subminer-skip-intro';
|
||||
export const ANISKIP_REFRESH_MESSAGE = 'subminer-aniskip-refresh';
|
||||
|
||||
const DEFAULT_ANISKIP_BUTTON_KEY = 'TAB';
|
||||
const LEGACY_ANISKIP_BUTTON_KEY = 'y-k';
|
||||
const ANISKIP_CHAPTER_PREFIX = 'AniSkip ';
|
||||
const SKIP_WINDOW_EPSILON_SECONDS = 0.35;
|
||||
const PROMPT_WINDOW_SECONDS = 3;
|
||||
const PROMPT_OSD_DURATION_MS = 3000;
|
||||
export interface AniSkipRuntimeConfig {
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
export interface AniSkipRuntimeDeps {
|
||||
getAniSkipConfig: () => AniSkipRuntimeConfig;
|
||||
resolveMetadataForFile: (mediaPath: string) => Promise<AniSkipMetadata>;
|
||||
sendMpvCommand: (command: unknown[]) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
isMpvConnected: () => boolean;
|
||||
getCurrentTimePos: () => number;
|
||||
showMpvOsd: (text: string, durationMs: number) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
logDebug: (message: string) => void;
|
||||
}
|
||||
|
||||
interface AniSkipIntroWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
malId: number | null;
|
||||
}
|
||||
|
||||
type MpvChapter = { time?: unknown; title?: unknown };
|
||||
|
||||
export function isRemoteMediaPath(mediaPath: string): boolean {
|
||||
return /^[a-zA-Z][\w+.-]*:\/\//.test(mediaPath.trim());
|
||||
}
|
||||
|
||||
export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
|
||||
let requestGeneration = 0;
|
||||
let currentMediaPath = '';
|
||||
let introWindow: AniSkipIntroWindow | null = null;
|
||||
let promptShown = false;
|
||||
let boundButtonKey: string | null = null;
|
||||
let legacyFallbackBound = false;
|
||||
const introWindowCache = new Map<string, AniSkipIntroWindow | null>();
|
||||
|
||||
function resolveButtonKey(): string {
|
||||
const key = deps.getAniSkipConfig().aniskipButtonKey.trim();
|
||||
return key || DEFAULT_ANISKIP_BUTTON_KEY;
|
||||
}
|
||||
|
||||
function bindSkipKeys(): void {
|
||||
if (!deps.isMpvConnected()) return;
|
||||
const enabled = deps.getAniSkipConfig().aniskipEnabled;
|
||||
const key = resolveButtonKey();
|
||||
const wantLegacyFallback =
|
||||
enabled && key !== LEGACY_ANISKIP_BUTTON_KEY && key !== DEFAULT_ANISKIP_BUTTON_KEY;
|
||||
|
||||
if (boundButtonKey && (!enabled || boundButtonKey !== key)) {
|
||||
deps.sendMpvCommand(['keybind', boundButtonKey, '']);
|
||||
boundButtonKey = null;
|
||||
}
|
||||
if (legacyFallbackBound && !wantLegacyFallback) {
|
||||
deps.sendMpvCommand(['keybind', LEGACY_ANISKIP_BUTTON_KEY, '']);
|
||||
legacyFallbackBound = false;
|
||||
}
|
||||
if (!enabled) return;
|
||||
|
||||
if (boundButtonKey !== key) {
|
||||
deps.sendMpvCommand(['keybind', key, `script-message ${ANISKIP_SKIP_INTRO_MESSAGE}`]);
|
||||
boundButtonKey = key;
|
||||
}
|
||||
if (wantLegacyFallback && !legacyFallbackBound) {
|
||||
deps.sendMpvCommand([
|
||||
'keybind',
|
||||
LEGACY_ANISKIP_BUTTON_KEY,
|
||||
`script-message ${ANISKIP_SKIP_INTRO_MESSAGE}`,
|
||||
]);
|
||||
legacyFallbackBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function setIntroChapters(introStart: number, introEnd: number): Promise<void> {
|
||||
let existing: MpvChapter[] = [];
|
||||
try {
|
||||
const chapterList = await deps.requestMpvProperty('chapter-list');
|
||||
if (Array.isArray(chapterList)) {
|
||||
existing = chapterList as MpvChapter[];
|
||||
}
|
||||
} catch {
|
||||
// chapter-list may be unavailable mid-load; fall back to AniSkip chapters only
|
||||
}
|
||||
const chapters = existing.filter(
|
||||
(chapter) =>
|
||||
typeof chapter?.title !== 'string' || !chapter.title.startsWith(ANISKIP_CHAPTER_PREFIX),
|
||||
);
|
||||
chapters.push({ time: introStart, title: 'AniSkip Intro Start' });
|
||||
chapters.push({ time: introEnd, title: 'AniSkip Intro End' });
|
||||
chapters.sort((a, b) => {
|
||||
const aTime = typeof a.time === 'number' ? a.time : 0;
|
||||
const bTime = typeof b.time === 'number' ? b.time : 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
deps.sendMpvCommand(['set_property', 'chapter-list', chapters]);
|
||||
}
|
||||
|
||||
async function removeIntroChapters(): Promise<void> {
|
||||
if (!deps.isMpvConnected()) return;
|
||||
let existing: MpvChapter[] = [];
|
||||
try {
|
||||
const chapterList = await deps.requestMpvProperty('chapter-list');
|
||||
if (Array.isArray(chapterList)) {
|
||||
existing = chapterList as MpvChapter[];
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const filtered = existing.filter(
|
||||
(chapter) =>
|
||||
typeof chapter?.title !== 'string' || !chapter.title.startsWith(ANISKIP_CHAPTER_PREFIX),
|
||||
);
|
||||
if (filtered.length !== existing.length) {
|
||||
deps.sendMpvCommand(['set_property', 'chapter-list', filtered]);
|
||||
}
|
||||
}
|
||||
|
||||
function clearState(): void {
|
||||
requestGeneration += 1;
|
||||
introWindow = null;
|
||||
promptShown = false;
|
||||
}
|
||||
|
||||
async function applyIntroWindow(window: AniSkipIntroWindow): Promise<void> {
|
||||
introWindow = window;
|
||||
promptShown = false;
|
||||
await setIntroChapters(window.start, window.end);
|
||||
deps.logInfo(
|
||||
`AniSkip intro window ${window.start.toFixed(3)} -> ${window.end.toFixed(3)} (MAL ${
|
||||
window.malId ?? '-'
|
||||
})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveForMedia(mediaPath: string, options?: { force?: boolean }): Promise<void> {
|
||||
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||
if (!mediaPath || isRemoteMediaPath(mediaPath)) {
|
||||
deps.logDebug('AniSkip lookup skipped: no local media path');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
introWindowCache.delete(mediaPath);
|
||||
}
|
||||
const generation = requestGeneration;
|
||||
const cached = introWindowCache.get(mediaPath);
|
||||
if (cached !== undefined) {
|
||||
if (cached) {
|
||||
await applyIntroWindow(cached);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let metadata: AniSkipMetadata;
|
||||
try {
|
||||
metadata = await deps.resolveMetadataForFile(mediaPath);
|
||||
} catch (error) {
|
||||
deps.logWarn('AniSkip metadata lookup failed', error);
|
||||
return;
|
||||
}
|
||||
if (generation !== requestGeneration || mediaPath !== currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
metadata.lookupStatus !== 'ready' ||
|
||||
metadata.introStart === null ||
|
||||
metadata.introEnd === null ||
|
||||
metadata.introEnd <= metadata.introStart
|
||||
) {
|
||||
// Only definitive "no skip window exists" results are cached; transient
|
||||
// lookup failures stay retryable on the next load or refresh.
|
||||
if (metadata.lookupStatus !== 'lookup_failed') {
|
||||
introWindowCache.set(mediaPath, null);
|
||||
}
|
||||
deps.logInfo(
|
||||
`AniSkip: no intro window for "${metadata.title}" (status=${metadata.lookupStatus ?? 'unknown'})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const window: AniSkipIntroWindow = {
|
||||
start: metadata.introStart,
|
||||
end: metadata.introEnd,
|
||||
malId: metadata.malId,
|
||||
};
|
||||
introWindowCache.set(mediaPath, window);
|
||||
await applyIntroWindow(window);
|
||||
}
|
||||
|
||||
function skipIntroNow(): void {
|
||||
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||
if (!introWindow) {
|
||||
deps.showMpvOsd('Intro skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
const now = deps.getCurrentTimePos();
|
||||
if (!Number.isFinite(now)) {
|
||||
deps.showMpvOsd('Skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
now < introWindow.start - SKIP_WINDOW_EPSILON_SECONDS ||
|
||||
now > introWindow.end + SKIP_WINDOW_EPSILON_SECONDS
|
||||
) {
|
||||
deps.showMpvOsd('Skip intro only during intro', PROMPT_OSD_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'time-pos', introWindow.end]);
|
||||
deps.showMpvOsd('Skipped intro', PROMPT_OSD_DURATION_MS);
|
||||
}
|
||||
|
||||
function handleTimePosChange({ time }: { time: number }): void {
|
||||
if (!introWindow || promptShown) return;
|
||||
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||
const promptWindowEnd = Math.min(introWindow.start + PROMPT_WINDOW_SECONDS, introWindow.end);
|
||||
if (time >= introWindow.start && time < promptWindowEnd) {
|
||||
promptShown = true;
|
||||
deps.showMpvOsd(`You can skip by pressing ${resolveButtonKey()}`, PROMPT_OSD_DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMediaPathChange({ path }: { path: string }): void {
|
||||
const nextPath = typeof path === 'string' ? path : '';
|
||||
if (nextPath === currentMediaPath && introWindow) {
|
||||
// Same-media reload: mpv rebuilt the chapter list, so re-apply markers.
|
||||
void setIntroChapters(introWindow.start, introWindow.end).catch(() => {});
|
||||
return;
|
||||
}
|
||||
currentMediaPath = nextPath;
|
||||
clearState();
|
||||
if (!nextPath) return;
|
||||
void resolveForMedia(nextPath).catch((error) => {
|
||||
deps.logWarn('AniSkip media resolution failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnectionChange({ connected }: { connected: boolean }): void {
|
||||
if (!connected) {
|
||||
boundButtonKey = null;
|
||||
legacyFallbackBound = false;
|
||||
clearState();
|
||||
currentMediaPath = '';
|
||||
return;
|
||||
}
|
||||
bindSkipKeys();
|
||||
}
|
||||
|
||||
function handleClientMessage({ args }: { args: string[] }): void {
|
||||
const messageName = args[0];
|
||||
if (messageName === ANISKIP_SKIP_INTRO_MESSAGE) {
|
||||
skipIntroNow();
|
||||
return;
|
||||
}
|
||||
if (messageName === ANISKIP_REFRESH_MESSAGE) {
|
||||
const mediaPath = currentMediaPath;
|
||||
if (!mediaPath) return;
|
||||
clearState();
|
||||
void removeIntroChapters().catch(() => {});
|
||||
void resolveForMedia(mediaPath, { force: true }).catch((error) => {
|
||||
deps.logWarn('AniSkip refresh failed', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfigChange(): void {
|
||||
bindSkipKeys();
|
||||
const enabled = deps.getAniSkipConfig().aniskipEnabled;
|
||||
if (!enabled) {
|
||||
clearState();
|
||||
void removeIntroChapters().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!introWindow && currentMediaPath) {
|
||||
void resolveForMedia(currentMediaPath).catch((error) => {
|
||||
deps.logWarn('AniSkip media resolution failed', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleConnectionChange,
|
||||
handleMediaPathChange,
|
||||
handleTimePosChange,
|
||||
handleClientMessage,
|
||||
applyConfigChange,
|
||||
skipIntroNow,
|
||||
getIntroWindow: () => introWindow,
|
||||
};
|
||||
}
|
||||
|
||||
export type AniSkipRuntime = ReturnType<typeof createAniSkipRuntime>;
|
||||
@@ -22,6 +22,7 @@ type ConfigHotReloadAppliedDeps = {
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
||||
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
||||
applyAniSkipConfig?: () => void;
|
||||
};
|
||||
|
||||
type ConfigHotReloadMessageDeps = {
|
||||
@@ -170,6 +171,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.setLogFileToggles?.(config.logging.files);
|
||||
}
|
||||
|
||||
if (hasAnyHotReloadField(diff, ['mpv.aniskipEnabled', 'mpv.aniskipButtonKey'])) {
|
||||
deps.applyAniSkipConfig?.();
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.broadcastToOverlayWindows('config:hot-reload', payload);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
||||
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
||||
applyAniSkipConfig?: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
@@ -99,6 +100,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
deps.setLogRotation?.(rotation),
|
||||
setLogFileToggles: (files: ResolvedConfig['logging']['files']) =>
|
||||
deps.setLogFileToggles?.(files),
|
||||
applyAniSkipConfig: () => deps.applyAniSkipConfig?.(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,6 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F8',
|
||||
}),
|
||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
@@ -105,8 +103,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_enabled=/);
|
||||
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||
|
||||
@@ -211,8 +211,6 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -224,8 +222,6 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly overridden', () => {
|
||||
@@ -243,8 +239,6 @@ test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly over
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F7',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -293,8 +287,6 @@ test('launchWindowsMpv attaches a launched video to a running app and disables p
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -357,8 +349,6 @@ test('launchWindowsMpv leaves plugin auto-start enabled when no running app cont
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -447,8 +437,6 @@ test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ export interface SubminerPluginRuntimeScriptOptConfig {
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
function boolScriptOpt(value: boolean): 'yes' | 'no' {
|
||||
@@ -34,7 +32,6 @@ export function buildSubminerPluginRuntimeScriptOptParts(
|
||||
const binaryPath = sanitizeScriptOptValue(runtimeConfig.binaryPath?.trim() || fallbackAppPath);
|
||||
const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath);
|
||||
const backend = sanitizeScriptOptValue(runtimeConfig.backend);
|
||||
const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey);
|
||||
return [
|
||||
`subminer-binary_path=${binaryPath}`,
|
||||
`subminer-socket_path=${socketPath}`,
|
||||
@@ -45,7 +42,5 @@ export function buildSubminerPluginRuntimeScriptOptParts(
|
||||
runtimeConfig.autoStartPauseUntilReady,
|
||||
)}`,
|
||||
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
|
||||
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
|
||||
`subminer-aniskip_button_key=${aniskipButtonKey}`,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user