feat(aniskip): move intro detection from mpv plugin to app runtime (#117)

This commit is contained in:
2026-06-09 23:55:43 -07:00
committed by GitHub
parent d5bfdcae7b
commit 2007e28be8
49 changed files with 900 additions and 1469 deletions
+1
View File
@@ -121,6 +121,7 @@ Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
<details>
<summary><b>Platform-specific install commands</b></summary>
+6
View File
@@ -0,0 +1,6 @@
type: changed
area: playback
- AniSkip intro detection now runs in the SubMiner app instead of the mpv plugin: lookups cover every local file loaded during an mpv session (including playlist advances), and the plugin no longer performs any network calls.
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` now hot-reload without restarting playback.
- AniSkip now requires the SubMiner app to be connected to mpv; plugin-only mpv sessions without the app no longer fetch skip windows.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: playback
- Fixed AniSkip intro markers disappearing after same-media mpv reloads.
- Fixed AniSkip metadata detection for intros that start at `0` seconds and common release-group filenames without `guessit`.
+2 -2
View File
@@ -634,8 +634,8 @@
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
}, // SubMiner-managed mpv launch and bundled plugin options.
// ==========================================
+1
View File
@@ -328,6 +328,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'YouTube', link: '/youtube-integration' },
{ text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' },
],
},
+51
View File
@@ -0,0 +1,51 @@
# AniSkip Integration
SubMiner integrates with [AniSkip](https://aniskip.com) to automatically detect anime intro intervals and let you skip them with a single key press.
Intro detection runs in the SubMiner app over the mpv IPC socket. It is available whenever the overlay is connected to mpv - not just at launch - and covers every local file loaded during an mpv session, including playlist advances.
## Setup
AniSkip is opt-in. Enable it in your config:
```jsonc
{
"mpv": {
"aniskipEnabled": true,
"aniskipButtonKey": "TAB",
},
}
```
Both settings hot-reload: changing them in your config takes effect immediately without restarting playback or mpv.
For best title and episode detection, install [`guessit`](https://github.com/guessit-io/guessit):
```bash
python3 -m pip install --user guessit
```
Without `guessit`, SubMiner falls back to an internal filename parser which handles most common naming conventions but may miss unusual formats.
## How It Works
On each local file load:
1. SubMiner infers the anime title, season, and episode number from the filename and path (using `guessit` if available, otherwise the built-in parser). Remote URLs are skipped entirely.
2. The title is matched against MyAnimeList to resolve a MAL id.
3. SubMiner queries the AniSkip API for an OP skip interval for that MAL id and episode.
4. If an interval is found, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapter markers to the current file and binds the skip key (`mpv.aniskipButtonKey`, default `TAB`).
5. At the start of the intro, an OSD prompt appears for 3 seconds: `You can skip by pressing TAB` (reflects your configured key). Pressing the key at any point during the intro seeks to the intro end.
Results are cached per file for the app session. Reload detection is also handled: if mpv reloads the same file, SubMiner re-applies the chapter markers without a new API lookup.
## Triggering from mpv
You can trigger AniSkip actions from mpv script-messages:
| Command | Effect |
| ------- | ------ |
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
These are handled by the SubMiner app over the IPC socket.
+2 -2
View File
@@ -30,7 +30,7 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
plugin/
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
# state · messages · hover · ui · options · environment · log
# binary · aniskip · aniskip_match)
# binary)
src/
ai/ # AI translation provider utilities (client, config)
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
@@ -130,7 +130,7 @@ src/renderer/
### Launcher + Plugin Runtimes
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `aniskip.lua` + `aniskip_match.lua` (intro-skip UX).
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
## Flow Diagram
+2 -2
View File
@@ -1468,8 +1468,8 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
+4 -19
View File
@@ -42,7 +42,7 @@ Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "
| `v` | Toggle primary subtitle bar visibility |
| `TAB` (default) | Skip intro (AniSkip) |
The AniSkip key is **not** a `y` chord. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it.
The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMiner app binds it over the mpv IPC socket while it is connected. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it. See [AniSkip Integration](/aniskip-integration) for setup and details.
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
@@ -133,10 +133,10 @@ script-message subminer-options
script-message subminer-restart
script-message subminer-status
script-message subminer-autoplay-ready
script-message subminer-aniskip-refresh
script-message subminer-skip-intro
```
The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) still exist, but they are handled by the SubMiner app over the IPC socket rather than by the plugin - see [AniSkip Integration](/aniskip-integration#triggering-from-mpv).
The `subminer-start` message accepts overrides:
```
@@ -146,26 +146,11 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
`log-level` here controls only logging verbosity passed to SubMiner.
`--debug` is a separate app/dev-mode flag in the main CLI and should not be used here for logging.
## AniSkip Intro Skip
- AniSkip lookups are gated. The plugin only runs lookup when:
- SubMiner launcher metadata is present, or
- SubMiner app process is already running, or
- You explicitly call `script-message subminer-aniskip-refresh`.
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
- MAL/title resolution is cached for the current mpv session.
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing TAB` by default; the key reflects `mpv.aniskipButtonKey`).
- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup.
## Lifecycle
For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
+2 -2
View File
@@ -634,8 +634,8 @@
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
}, // SubMiner-managed mpv launch and bundled plugin options.
// ==========================================
+3 -3
View File
@@ -265,10 +265,10 @@ script-message subminer-options
script-message subminer-restart
script-message subminer-status
script-message subminer-autoplay-ready
script-message subminer-aniskip-refresh
script-message subminer-skip-intro
```
The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) are handled by the SubMiner app over the mpv IPC socket while it is connected.
The start command also accepts inline overrides:
```text
@@ -283,7 +283,7 @@ Examples:
- send `subminer-start` after your own media-selection script chooses a file
- send `subminer-status` before running follow-up automation
- send `subminer-aniskip-refresh` after you update title/episode metadata
- send `subminer-aniskip-refresh` after you update title/episode metadata (handled by the SubMiner app)
#### Build a launcher wrapper
@@ -46,8 +46,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {},
@@ -83,8 +83,6 @@ function createContext(): LauncherCommandContext {
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
@@ -210,8 +208,6 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
@@ -273,8 +269,6 @@ test('plugin auto-start playback attaches a warm background app through the laun
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = [];
@@ -342,8 +336,6 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined;
@@ -404,8 +396,6 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
-20
View File
@@ -91,8 +91,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
autoStartSubMiner: false,
pauseUntilOverlayReady: false,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
@@ -102,8 +100,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
assert.equal(parsed.autoStartSubMiner, false);
assert.equal(parsed.pauseUntilOverlayReady, false);
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
@@ -138,8 +134,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
@@ -150,8 +144,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
@@ -161,8 +153,6 @@ test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, true);
assert.equal(parsed.aniskipButtonKey, 'TAB');
});
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
@@ -176,8 +166,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
'/fallback/SubMiner.AppImage',
),
@@ -189,8 +177,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
'subminer-auto_start_visible_overlay=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8',
],
);
});
@@ -206,8 +192,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8,\nF9',
},
'/fallback/SubMiner.AppImage',
),
@@ -219,8 +203,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
'subminer-auto_start_visible_overlay=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8 F9',
],
);
});
@@ -244,8 +226,6 @@ test('parseLauncherMpvConfig reads configured mpv profile', () => {
pauseUntilOverlayReady: undefined,
subminerBinaryPath: undefined,
profile: 'anime',
aniskipEnabled: undefined,
aniskipButtonKey: undefined,
},
);
-2
View File
@@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
pauseUntilOverlayReady:
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
};
}
+1 -3
View File
@@ -54,8 +54,6 @@ export function parsePluginRuntimeConfigFromMainConfig(
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
};
}
@@ -72,7 +70,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log(
'debug',
logLevel,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}`,
);
return parsed;
}
-35
View File
@@ -23,8 +23,6 @@ import {
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadataForLaunch,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
state,
@@ -388,31 +386,12 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
}),
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
);
});
test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin AniSkip', () => {
assert.equal(
shouldResolveAniSkipMetadataForLaunch('/tmp/video.mkv', 'file', undefined, {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'TAB',
}),
false,
);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
@@ -565,20 +544,6 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
}
});
test('shouldResolveAniSkipMetadata skips URL and YouTube-preloaded playback', () => {
assert.equal(shouldResolveAniSkipMetadata('/media/show.mkv', 'file'), true);
assert.equal(
shouldResolveAniSkipMetadata('https://www.youtube.com/watch?v=test123', 'url'),
false,
);
assert.equal(
shouldResolveAniSkipMetadata('/tmp/video123.webm', 'file', {
primaryPath: '/tmp/video123.ja.srt',
}),
false,
);
});
function makeArgs(overrides: Partial<Args> = {}): Args {
return {
backend: 'x11',
+3 -50
View File
@@ -27,7 +27,7 @@ import {
shouldForwardLogLevel,
} from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { buildSubminerScriptOpts } from './script-opts.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
import { nowMs } from './time.js';
import {
@@ -823,20 +823,6 @@ export async function loadSubtitleIntoMpv(
}
}
export function shouldResolveAniSkipMetadata(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
): boolean {
if (targetKind !== 'file') {
return false;
}
if (preloadedSubtitles?.primaryPath || preloadedSubtitles?.secondaryPath) {
return false;
}
return !isYoutubeTarget(target);
}
type StartMpvOptions = {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
@@ -844,18 +830,6 @@ type StartMpvOptions = {
runtimePluginConfig?: PluginRuntimeConfig;
};
export function shouldResolveAniSkipMetadataForLaunch(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
runtimePluginConfig?: PluginRuntimeConfig,
): boolean {
if (runtimePluginConfig?.aniskipEnabled === false) {
return false;
}
return shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles);
}
export function buildRuntimeExtraScriptOptParts(
target: string,
targetKind: 'file' | 'url',
@@ -946,29 +920,14 @@ export async function startMpv(
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
target,
targetKind,
preloadedSubtitles,
options?.runtimePluginConfig,
)
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
const runtimeScriptOpts = options?.runtimePluginConfig
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, [
...runtimeScriptOpts,
...extraScriptOpts,
]);
if (aniSkipMetadata) {
log(
'debug',
args.logLevel,
`AniSkip metadata (${aniSkipMetadata.source}): title="${aniSkipMetadata.title}" season=${aniSkipMetadata.season ?? '-'} episode=${aniSkipMetadata.episode ?? '-'}`,
);
}
mpvArgs.push(`--script-opts=${scriptOpts}`);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
@@ -1701,13 +1660,7 @@ export function launchMpvIdleDetached(
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
mpvArgs.push(
`--script-opts=${buildSubminerScriptOpts(
appPath,
socketPath,
null,
args.logLevel,
runtimeScriptOpts,
)}`,
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, runtimeScriptOpts)}`,
);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
mpvArgs.push(`--input-ipc-server=${socketPath}`);
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildSubminerScriptOpts } from './script-opts';
test('buildSubminerScriptOpts preserves app and socket paths verbatim', () => {
const scriptOpts = buildSubminerScriptOpts(
'/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner',
'/tmp/subminer socket.sock',
['subminer-backend=x11'],
);
assert.equal(
scriptOpts,
'subminer-binary_path=/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner,subminer-socket_path=/tmp/subminer socket.sock,subminer-backend=x11',
);
});
test('buildSubminerScriptOpts rejects delimiter-bearing default paths', () => {
assert.throws(
() => buildSubminerScriptOpts('/tmp/SubMiner,canary', '/tmp/subminer.sock'),
/subminer-binary_path contains unsupported script option delimiter/,
);
assert.throws(
() => buildSubminerScriptOpts('/tmp/SubMiner', '/tmp/subminer\nsocket.sock'),
/subminer-socket_path contains unsupported script option delimiter/,
);
});
+34
View File
@@ -0,0 +1,34 @@
function sanitizeScriptOptValue(value: string): string {
return value
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function assertScriptOptPathValue(name: string, value: string): void {
if (/[,\r\n]/.test(value)) {
throw new Error(`${name} contains unsupported script option delimiter`);
}
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
extraParts: string[] = [],
): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
if (!hasBinaryPath) {
assertScriptOptPathValue('subminer-binary_path', appPath);
}
if (!hasSocketPath) {
assertScriptOptPathValue('subminer-socket_path', socketPath);
}
const parts = [
...(hasBinaryPath ? [] : [`subminer-binary_path=${appPath}`]),
...(hasSocketPath ? [] : [`subminer-socket_path=${socketPath}`]),
...extraParts.map(sanitizeScriptOptValue),
];
return parts.join(',');
}
-1
View File
@@ -559,7 +559,6 @@ test(
socketPath: smokeCase.socketPath,
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
aniskipEnabled: false,
},
}),
);
-4
View File
@@ -191,8 +191,6 @@ export interface LauncherMpvConfig {
autoStartSubMiner?: boolean;
pauseUntilOverlayReady?: boolean;
subminerBinaryPath?: string;
aniskipEnabled?: boolean;
aniskipButtonKey?: string;
}
export interface LauncherLoggingConfig {
@@ -210,8 +208,6 @@ export interface PluginRuntimeConfig {
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
texthookerEnabled: boolean;
aniskipEnabled: boolean;
aniskipButtonKey: string;
}
export interface CommandExecOptions {
-758
View File
@@ -1,758 +0,0 @@
local M = {}
local matcher = require("aniskip_match")
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
function M.create(ctx)
local mp = ctx.mp
local utils = ctx.utils
local opts = ctx.opts
local state = ctx.state
local environment = ctx.environment
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
local request_generation = 0
local mal_lookup_cache = {}
local payload_cache = {}
local title_context_cache = {}
local base64_reverse = {}
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
for i = 1, #base64_chars do
base64_reverse[base64_chars:sub(i, i)] = i - 1
end
local function url_encode(text)
if type(text) ~= "string" then
return ""
end
local encoded = text:gsub("\n", " ")
encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
return string.format("%%%02X", string.byte(char))
end)
return encoded:gsub(" ", "%%20")
end
local function is_remote_media_path()
local media_path = mp.get_property("path")
if type(media_path) ~= "string" then
return false
end
local trimmed = media_path:match("^%s*(.-)%s*$") or ""
if trimmed == "" then
return false
end
return trimmed:match("^%a[%w+.-]*://") ~= nil
end
local function parse_json_payload(text)
if type(text) ~= "string" then
return nil
end
local parsed, parse_error = utils.parse_json(text)
if type(parsed) == "table" then
return parsed
end
return nil, parse_error
end
local function decode_base64(input)
if type(input) ~= "string" then
return nil
end
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
if cleaned == "" then
return nil
end
if #cleaned % 4 == 1 then
return nil
end
if #cleaned % 4 ~= 0 then
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
end
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
return nil
end
local out = {}
local out_len = 0
for index = 1, #cleaned, 4 do
local c1 = cleaned:sub(index, index)
local c2 = cleaned:sub(index + 1, index + 1)
local c3 = cleaned:sub(index + 2, index + 2)
local c4 = cleaned:sub(index + 3, index + 3)
local v1 = base64_reverse[c1]
local v2 = base64_reverse[c2]
if not v1 or not v2 then
return nil
end
local v3 = c3 == "=" and 0 or base64_reverse[c3]
local v4 = c4 == "=" and 0 or base64_reverse[c4]
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
return nil
end
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
local b1 = math.floor(n / 65536)
local remaining = n % 65536
local b2 = math.floor(remaining / 256)
local b3 = remaining % 256
out_len = out_len + 1
out[out_len] = string.char(b1)
if c3 ~= "=" then
out_len = out_len + 1
out[out_len] = string.char(b2)
end
if c4 ~= "=" then
out_len = out_len + 1
out[out_len] = string.char(b3)
end
end
return table.concat(out)
end
local function resolve_launcher_payload()
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
if trimmed == "" then
return nil
end
local parsed, parse_error = parse_json_payload(trimmed)
if type(parsed) == "table" then
return parsed
end
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
local value = tonumber(hex, 16)
if value then
return string.char(value)
end
return "%"
end)
if url_decoded ~= trimmed then
parsed, parse_error = parse_json_payload(url_decoded)
if type(parsed) == "table" then
return parsed
end
end
local b64_decoded = decode_base64(trimmed)
if type(b64_decoded) == "string" and b64_decoded ~= "" then
parsed, parse_error = parse_json_payload(b64_decoded)
if type(parsed) == "table" then
return parsed
end
end
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
return nil
end
local function run_json_curl_async(url, callback)
mp.command_native_async({
name = "subprocess",
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
local detail = error or (result and result.stderr) or "curl failed"
callback(nil, detail)
return
end
local parsed, parse_error = utils.parse_json(result.stdout)
if type(parsed) ~= "table" then
callback(nil, parse_error or "invalid json")
return
end
callback(parsed, nil)
end)
end
local function parse_episode_hint(text)
if type(text) ~= "string" or text == "" then
return nil
end
local patterns = {
"[Ss]%d+[Ee](%d+)",
"[Ee][Pp]?[%s%._%-]*(%d+)",
"[%s%._%-]+(%d+)[%s%._%-]+",
}
for _, pattern in ipairs(patterns) do
local token = text:match(pattern)
if token then
local episode = tonumber(token)
if episode and episode > 0 and episode < 10000 then
return episode
end
end
end
return nil
end
local function cleanup_title(raw)
if type(raw) ~= "string" then
return nil
end
local cleaned = raw
cleaned = cleaned:gsub("%b[]", " ")
cleaned = cleaned:gsub("%b()", " ")
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
cleaned = cleaned:gsub("[%._%-]+", " ")
cleaned = cleaned:gsub("%s+", " ")
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
if cleaned == "" then
return nil
end
return cleaned
end
local function extract_show_title_from_path(media_path)
if type(media_path) ~= "string" or media_path == "" then
return nil
end
local normalized = media_path:gsub("\\", "/")
local segments = {}
for segment in normalized:gmatch("[^/]+") do
segments[#segments + 1] = segment
end
for index = 1, #segments do
local segment = segments[index] or ""
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
local prior = segments[index - 1]
local cleaned = cleanup_title(prior or "")
if cleaned and cleaned ~= "" then
return cleaned
end
end
end
return nil
end
local function resolve_title_and_episode()
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
local forced_season = tonumber(opts.aniskip_season)
local forced_episode = tonumber(opts.aniskip_episode)
local media_title = mp.get_property("media-title")
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
local path = mp.get_property("path") or ""
local cache_key = table.concat({
tostring(forced_title or ""),
tostring(forced_season or ""),
tostring(forced_episode or ""),
tostring(media_title or ""),
tostring(filename or ""),
tostring(path or ""),
}, "\31")
local cached = title_context_cache[cache_key]
if type(cached) == "table" then
return cached.title, cached.episode, cached.season
end
local path_show_title = extract_show_title_from_path(path)
local candidate_title = nil
if path_show_title and path_show_title ~= "" then
candidate_title = path_show_title
elseif forced_title ~= "" then
candidate_title = forced_title
else
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
end
local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1
title_context_cache[cache_key] = {
title = candidate_title,
episode = episode,
season = forced_season,
}
return candidate_title, episode, forced_season
end
local function select_best_mal_item(items, title, season)
if type(items) ~= "table" then
return nil
end
local best_item = nil
local best_score = -math.huge
for _, item in ipairs(items) do
if type(item) == "table" and tonumber(item.id) then
local candidate_name = tostring(item.name or "")
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
if score > best_score then
best_score = score
best_item = item
end
end
end
return best_item
end
local function resolve_mal_id_async(title, season, request_id, callback)
local forced_mal_id = tonumber(opts.aniskip_mal_id)
if forced_mal_id and forced_mal_id > 0 then
callback(forced_mal_id, "(forced-mal-id)")
return
end
if type(title) == "string" and title:match("^%d+$") then
local numeric = tonumber(title)
if numeric and numeric > 0 then
callback(numeric, title)
return
end
end
if type(title) ~= "string" or title == "" then
callback(nil, nil)
return
end
local lookup = title
if season and season > 1 then
lookup = string.format("%s Season %d", lookup, season)
end
local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-"))
local cached = mal_lookup_cache[cache_key]
if cached ~= nil then
if cached == false then
callback(nil, lookup)
else
callback(cached, lookup)
end
return
end
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
run_json_curl_async(mal_url, function(mal_json, mal_error)
if request_id ~= request_generation then
return
end
if not mal_json then
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
callback(nil, lookup)
return
end
local categories = mal_json.categories
if type(categories) ~= "table" then
mal_lookup_cache[cache_key] = false
callback(nil, lookup)
return
end
local all_items = {}
for _, category in ipairs(categories) do
if type(category) == "table" and type(category.items) == "table" then
for _, item in ipairs(category.items) do
all_items[#all_items + 1] = item
end
end
end
local best_item = select_best_mal_item(all_items, title, season)
if best_item and tonumber(best_item.id) then
local matched_id = tonumber(best_item.id)
mal_lookup_cache[cache_key] = matched_id
subminer_log(
"info",
"aniskip",
string.format(
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
tostring(best_item.id),
tostring(best_item.name or ""),
tostring(season or "-")
)
)
callback(matched_id, lookup)
return
end
mal_lookup_cache[cache_key] = false
callback(nil, lookup)
end)
end
local function set_intro_chapters(intro_start, intro_end)
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
return
end
local current = mp.get_property_native("chapter-list")
local chapters = {}
if type(current) == "table" then
for _, chapter in ipairs(current) do
local title = type(chapter) == "table" and chapter.title or nil
if type(title) ~= "string" or not title:match("^AniSkip ") then
chapters[#chapters + 1] = chapter
end
end
end
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
table.sort(chapters, function(a, b)
local a_time = type(a) == "table" and tonumber(a.time) or 0
local b_time = type(b) == "table" and tonumber(b.time) or 0
return a_time < b_time
end)
mp.set_property_native("chapter-list", chapters)
end
local function remove_aniskip_chapters()
local current = mp.get_property_native("chapter-list")
if type(current) ~= "table" then
return
end
local chapters = {}
local changed = false
for _, chapter in ipairs(current) do
local title = type(chapter) == "table" and chapter.title or nil
if type(title) == "string" and title:match("^AniSkip ") then
changed = true
else
chapters[#chapters + 1] = chapter
end
end
if changed then
mp.set_property_native("chapter-list", chapters)
end
end
local function reset_aniskip_fields()
state.aniskip.prompt_shown = false
state.aniskip.found = false
state.aniskip.mal_id = nil
state.aniskip.title = nil
state.aniskip.episode = nil
state.aniskip.intro_start = nil
state.aniskip.intro_end = nil
state.aniskip.payload = nil
state.aniskip.payload_source = nil
remove_aniskip_chapters()
end
local function clear_aniskip_state()
request_generation = request_generation + 1
reset_aniskip_fields()
end
local function skip_intro_now()
if not state.aniskip.found then
show_osd("Intro skip unavailable")
return
end
local intro_start = state.aniskip.intro_start
local intro_end = state.aniskip.intro_end
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
show_osd("Intro markers missing")
return
end
local now = mp.get_property_number("time-pos")
if type(now) ~= "number" then
show_osd("Skip unavailable")
return
end
local epsilon = 0.35
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
show_osd("Skip intro only during intro")
return
end
mp.set_property_number("time-pos", intro_end)
show_osd("Skipped intro")
end
local function update_intro_button_visibility()
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
return
end
local now = mp.get_property_number("time-pos")
if type(now) ~= "number" then
return
end
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
local intro_start = state.aniskip.intro_start or -1
local hint_window_end = intro_start + 3
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY
local message = string.format(opts.aniskip_button_text, key)
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
state.aniskip.prompt_shown = true
end
end
local function apply_aniskip_payload(mal_id, title, episode, payload)
local results = payload and payload.results
if type(results) ~= "table" then
return false
end
for _, item in ipairs(results) do
if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
local intro_start = tonumber(item.interval.start_time)
local intro_end = tonumber(item.interval.end_time)
if intro_start and intro_end and intro_end > intro_start then
state.aniskip.found = true
state.aniskip.mal_id = mal_id
state.aniskip.title = title
state.aniskip.episode = episode
state.aniskip.intro_start = intro_start
state.aniskip.intro_end = intro_end
state.aniskip.prompt_shown = false
set_intro_chapters(intro_start, intro_end)
subminer_log(
"info",
"aniskip",
string.format(
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
intro_start,
intro_end,
tostring(mal_id or "-"),
tostring(episode or "-")
)
)
return true
end
end
end
return false
end
local function has_launcher_payload()
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
end
local function is_launcher_context()
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
if forced_title ~= "" then
return true
end
local forced_mal_id = tonumber(opts.aniskip_mal_id)
if forced_mal_id and forced_mal_id > 0 then
return true
end
local forced_episode = tonumber(opts.aniskip_episode)
if forced_episode and forced_episode > 0 then
return true
end
local forced_season = tonumber(opts.aniskip_season)
if forced_season and forced_season > 0 then
return true
end
if has_launcher_payload() then
return true
end
return false
end
local function should_fetch_aniskip_async(trigger_source, callback)
if is_remote_media_path() then
callback(false, "remote-url")
return
end
if trigger_source == "script-message" or trigger_source == "overlay-start" then
callback(true, trigger_source)
return
end
if is_launcher_context() then
callback(true, "launcher-context")
return
end
if type(environment.is_subminer_app_running_async) == "function" then
environment.is_subminer_app_running_async(function(running)
if running then
callback(true, "subminer-app-running")
else
callback(false, "subminer-context-missing")
end
end)
return
end
if environment.is_subminer_app_running() then
callback(true, "subminer-app-running")
return
end
callback(false, "subminer-context-missing")
end
local function resolve_lookup_titles(primary_title)
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
local path_fallback = cleanup_title(mp.get_property("path") or "")
local lookup_titles = {}
local seen_titles = {}
local function push_lookup_title(candidate)
if type(candidate) ~= "string" then
return
end
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
if trimmed == "" then
return
end
local key = trimmed:lower()
if seen_titles[key] then
return
end
seen_titles[key] = true
lookup_titles[#lookup_titles + 1] = trimmed
end
push_lookup_title(primary_title)
push_lookup_title(media_title_fallback)
push_lookup_title(filename_fallback)
push_lookup_title(path_fallback)
return lookup_titles
end
local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup)
local current_index = index or 1
local current_lookup = last_lookup
if current_index > #lookup_titles then
callback(nil, current_lookup)
return
end
local lookup_title = lookup_titles[current_index]
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title))
resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup)
if request_id ~= request_generation then
return
end
if mal_id then
callback(mal_id, lookup)
return
end
resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup)
end)
end
local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback)
local payload_cache_key = string.format("%d:%d", mal_id, episode)
local cached_payload = payload_cache[payload_cache_key]
if cached_payload ~= nil then
if cached_payload == false then
callback(nil, nil, true)
else
callback(cached_payload, nil, true)
end
return
end
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
subminer_log("info", "aniskip", string.format("AniSkip URL=%s", url))
run_json_curl_async(url, function(payload, fetch_error)
if request_id ~= request_generation then
return
end
if not payload then
callback(nil, fetch_error, false)
return
end
if payload.found ~= true then
payload_cache[payload_cache_key] = false
callback(nil, nil, false)
return
end
payload_cache[payload_cache_key] = payload
callback(payload, nil, false)
end)
end
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
if not payload then
return false
end
state.aniskip.payload = payload
state.aniskip.payload_source = "launcher"
state.aniskip.mal_id = mal_id
state.aniskip.title = title
state.aniskip.episode = episode
return apply_aniskip_payload(mal_id, title, episode, payload)
end
local function fetch_aniskip_for_current_media(trigger_source)
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
if not opts.aniskip_enabled then
clear_aniskip_state()
return
end
should_fetch_aniskip_async(trigger, function(allowed, reason)
if not allowed then
subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason))
return
end
request_generation = request_generation + 1
local request_id = request_generation
reset_aniskip_fields()
local title, episode, season = resolve_title_and_episode()
local lookup_titles = resolve_lookup_titles(title)
local launcher_payload = resolve_launcher_payload()
if launcher_payload then
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
if not launcher_mal_id then
launcher_mal_id = nil
end
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
subminer_log(
"info",
"aniskip",
string.format(
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
tostring(title or ""),
tostring(season or "-"),
tostring(episode or "-")
)
)
return
end
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
return
end
subminer_log(
"info",
"aniskip",
string.format(
'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
tostring(trigger),
tostring(reason or "-"),
tostring(title or ""),
tostring(season or "-"),
tostring(episode or "-"),
tostring(opts.aniskip_title or ""),
tostring(opts.aniskip_season or "-"),
tostring(opts.aniskip_episode or "-"),
tostring(opts.aniskip_mal_id or "-"),
#lookup_titles
)
)
resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup)
if request_id ~= request_generation then
return
end
if not mal_id then
subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")))
return
end
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or "")))
fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error)
if request_id ~= request_generation then
return
end
if not payload then
if fetch_error then
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
else
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
end
return
end
state.aniskip.payload = payload
state.aniskip.payload_source = "remote"
if not apply_aniskip_payload(mal_id, title, episode, payload) then
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
end
end)
end)
end)
end
return {
clear_aniskip_state = clear_aniskip_state,
skip_intro_now = skip_intro_now,
update_intro_button_visibility = update_intro_button_visibility,
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
}
end
return M
-150
View File
@@ -1,150 +0,0 @@
local M = {}
local function normalize_for_match(value)
if type(value) ~= "string" then
return ""
end
return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
end
local MATCH_STOPWORDS = {
the = true,
this = true,
that = true,
world = true,
animated = true,
series = true,
season = true,
no = true,
on = true,
["and"] = true,
}
local function tokenize_match_words(value)
local normalized = normalize_for_match(value)
local tokens = {}
for token in normalized:gmatch("%S+") do
if #token >= 3 and not MATCH_STOPWORDS[token] then
tokens[#tokens + 1] = token
end
end
return tokens
end
local function token_set(tokens)
local set = {}
for _, token in ipairs(tokens) do
set[token] = true
end
return set
end
function M.title_overlap_score(expected_title, candidate_title)
local expected = normalize_for_match(expected_title)
local candidate = normalize_for_match(candidate_title)
if expected == "" or candidate == "" then
return 0
end
if candidate:find(expected, 1, true) then
return 120
end
local expected_tokens = tokenize_match_words(expected_title)
local candidate_tokens = token_set(tokenize_match_words(candidate_title))
if #expected_tokens == 0 then
return 0
end
local score = 0
local matched = 0
for _, token in ipairs(expected_tokens) do
if candidate_tokens[token] then
score = score + 30
matched = matched + 1
else
score = score - 20
end
end
if matched == 0 then
score = score - 80
end
local coverage = matched / #expected_tokens
if #expected_tokens >= 2 then
if coverage >= 0.8 then
score = score + 30
elseif coverage >= 0.6 then
score = score + 10
else
score = score - 50
end
elseif coverage >= 1 then
score = score + 10
end
return score
end
local function has_any_sequel_marker(candidate_title)
local normalized = normalize_for_match(candidate_title)
if normalized == "" then
return false
end
local markers = {
"season 2",
"season 3",
"season 4",
"2nd season",
"3rd season",
"4th season",
"second season",
"third season",
"fourth season",
" ii ",
" iii ",
" iv ",
}
local padded = " " .. normalized .. " "
for _, marker in ipairs(markers) do
if padded:find(marker, 1, true) then
return true
end
end
return false
end
function M.season_signal_score(requested_season, candidate_title)
local season = tonumber(requested_season)
if not season or season < 1 then
return 0
end
local normalized = " " .. normalize_for_match(candidate_title) .. " "
if normalized == " " then
return 0
end
if season == 1 then
return has_any_sequel_marker(candidate_title) and -60 or 20
end
local numeric_marker = string.format(" season %d ", season)
local ordinal_marker = string.format(" %dth season ", season)
local roman_markers = {
[2] = { " ii ", " second season ", " 2nd season " },
[3] = { " iii ", " third season ", " 3rd season " },
[4] = { " iv ", " fourth season ", " 4th season " },
[5] = { " v ", " fifth season ", " 5th season " },
}
if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
return 40
end
local aliases = roman_markers[season] or {}
for _, marker in ipairs(aliases) do
if normalized:find(marker, 1, true) then
return 40
end
end
if has_any_sequel_marker(candidate_title) then
return -20
end
return 5
end
return M
-3
View File
@@ -56,9 +56,6 @@ function M.init()
ctx.binary = make_lazy_proxy("binary", function()
return require("binary").create(ctx)
end)
ctx.aniskip = make_lazy_proxy("aniskip", function()
return require("aniskip").create(ctx)
end)
ctx.hover = make_lazy_proxy("hover", function()
return require("hover").create(ctx)
end)
-25
View File
@@ -10,7 +10,6 @@ function M.create(ctx)
local state = ctx.state
local options_helper = ctx.options_helper
local process = ctx.process
local aniskip = ctx.aniskip
local hover = ctx.hover
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
@@ -52,13 +51,6 @@ function M.create(ctx)
return reason == "reload" or reason == "redirect"
end
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
local delay = tonumber(delay_seconds) or 0
mp.add_timeout(delay, function()
aniskip.fetch_aniskip_for_current_media(trigger_source)
end)
end
local function clear_pending_visible_overlay_hide()
local timer = state.pending_visible_overlay_hide_timer
if timer and timer.kill then
@@ -159,7 +151,6 @@ function M.create(ctx)
return
end
if not resolve_auto_start_enabled() then
schedule_aniskip_fetch("file-loaded", 0)
return
end
@@ -178,7 +169,6 @@ function M.create(ctx)
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
.. ")"
)
schedule_aniskip_fetch("file-loaded", 0)
return
end
@@ -187,8 +177,6 @@ function M.create(ctx)
socket_path = opts.socket_path,
rearm_pause_until_ready = should_rearm_pause_until_ready(same_media_loaded),
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
end
local function on_start_file()
@@ -267,7 +255,6 @@ function M.create(ctx)
local preserve_active_auto_start_gate = (
state.overlay_running and state.auto_play_ready_gate_armed and should_auto_start and has_matching_socket
)
aniskip.clear_aniskip_state()
if not preserve_active_auto_start_gate then
process.disarm_auto_play_ready_gate()
end
@@ -283,12 +270,10 @@ function M.create(ctx)
end
refresh_managed_subtitle_autoloading()
schedule_aniskip_fetch("file-loaded", 0)
end
local function on_shutdown()
next_auto_start_retry_generation()
aniskip.clear_aniskip_state()
hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
clear_pending_visible_overlay_hide()
@@ -334,22 +319,12 @@ function M.create(ctx)
mp.register_event("shutdown", function()
hover.clear_hover_overlay()
end)
mp.register_event("end-file", function()
aniskip.clear_aniskip_state()
end)
mp.register_event("shutdown", function()
aniskip.clear_aniskip_state()
end)
mp.add_hook("on_unload", 10, function()
hover.clear_hover_overlay()
aniskip.clear_aniskip_state()
end)
mp.observe_property("sub-start", "native", function()
hover.clear_hover_overlay()
end)
mp.observe_property("time-pos", "number", function()
aniskip.update_intro_button_visibility()
end)
end
return {
-7
View File
@@ -3,7 +3,6 @@ local M = {}
function M.create(ctx)
local mp = ctx.mp
local process = ctx.process
local aniskip = ctx.aniskip
local hover = ctx.hover
local ui = ctx.ui
local state = ctx.state
@@ -43,12 +42,6 @@ function M.create(ctx)
mp.register_script_message("subminer-autoplay-ready", function()
process.notify_auto_play_ready()
end)
mp.register_script_message("subminer-aniskip-refresh", function()
aniskip.fetch_aniskip_for_current_media("script-message")
end)
mp.register_script_message("subminer-skip-intro", function()
aniskip.skip_intro_now()
end)
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
hover.handle_hover_message(payload_json)
end)
-11
View File
@@ -1,5 +1,4 @@
local M = {}
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
local function normalize_socket_path_option(socket_path, default_socket_path)
if type(default_socket_path) ~= "string" then
@@ -37,16 +36,6 @@ function M.load(options_lib, default_socket_path)
auto_start_pause_until_ready_timeout_seconds = 15,
osd_messages = true,
log_level = "info",
aniskip_enabled = false,
aniskip_title = "",
aniskip_season = "",
aniskip_mal_id = "",
aniskip_episode = "",
aniskip_payload = "",
aniskip_show_button = true,
aniskip_button_text = "You can skip by pressing %s",
aniskip_button_key = DEFAULT_ANISKIP_BUTTON_KEY,
aniskip_button_duration = 3,
}
options_lib.read_options(opts, "subminer")
-11
View File
@@ -18,17 +18,6 @@ function M.new()
clear_timer = nil,
last_hover_update_ts = 0,
},
aniskip = {
mal_id = nil,
title = nil,
episode = nil,
intro_start = nil,
intro_end = nil,
payload = nil,
payload_source = nil,
found = false,
prompt_shown = false,
},
auto_play_ready_gate_armed = false,
auto_play_ready_should_resume_playback = false,
auto_play_ready_timeout = nil,
-17
View File
@@ -1,13 +1,9 @@
local M = {}
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
local LEGACY_ANISKIP_BUTTON_KEY = "y-k"
function M.create(ctx)
local mp = ctx.mp
local input = ctx.input
local opts = ctx.opts
local process = ctx.process
local aniskip = ctx.aniskip
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
@@ -99,19 +95,6 @@ function M.create(ctx)
end
process.run_control_command_async("open-session-help")
end)
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
aniskip.skip_intro_now()
end)
end
if
opts.aniskip_button_key ~= LEGACY_ANISKIP_BUTTON_KEY
and opts.aniskip_button_key ~= DEFAULT_ANISKIP_BUTTON_KEY
then
mp.add_key_binding(LEGACY_ANISKIP_BUTTON_KEY, "subminer-skip-intro-fallback", function()
aniskip.skip_intro_now()
end)
end
end
return {
+8 -164
View File
@@ -87,13 +87,6 @@ local function run_plugin_scenario(config)
}
end
if args[1] == "curl" then
local url = args[#args] or ""
if type(url) == "string" and url:find("myanimelist", 1, true) then
return { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }
end
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
return { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }
end
return { status = 0, stdout = "{}", stderr = "" }
end
return { status = 0, stdout = "", stderr = "" }
@@ -108,15 +101,8 @@ local function run_plugin_scenario(config)
return
end
if args[1] == "curl" then
local url = args[#args] or ""
if type(url) == "string" and url:find("myanimelist", 1, true) then
callback(true, { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }, nil)
return
end
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
callback(true, { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }, nil)
return
end
callback(true, { status = 0, stdout = "{}", stderr = "" }, nil)
return
end
for _, value in ipairs(args) do
if value == "--app-ping" then
@@ -263,34 +249,6 @@ local function run_plugin_scenario(config)
amount = 125,
}, nil
end
if json == "__MAL_FOUND__" then
return {
categories = {
{
items = {
{
id = 99,
name = "Sample Show",
},
},
},
},
}, nil
end
if json == "__ANISKIP_FOUND__" then
return {
found = true,
results = {
{
skip_type = "op",
interval = {
start_time = 12.3,
end_time = 45.6,
},
},
},
}, nil
end
return {}, nil
end
@@ -311,7 +269,6 @@ local function run_plugin_scenario(config)
package.loaded["process"] = nil
package.loaded["state"] = nil
package.loaded["ui"] = nil
package.loaded["aniskip"] = nil
_G.__subminer_plugin_bootstrapped = nil
local original_package_config = package.config
if config.platform == "windows" then
@@ -505,33 +462,6 @@ local function has_async_command(async_calls, executable)
return false
end
local function has_async_curl_for(async_calls, needle)
for _, call in ipairs(async_calls) do
local args = call.args or {}
if args[1] == "curl" then
local url = args[#args] or ""
if type(url) == "string" and url:find(needle, 1, true) then
return true
end
end
end
return false
end
local function count_async_curl_for(async_calls, needle)
local count = 0
for _, call in ipairs(async_calls) do
local args = call.args or {}
if args[1] == "curl" then
local url = args[#args] or ""
if type(url) == "string" and url:find(needle, 1, true) then
count = count + 1
end
end
end
return count
end
local function has_property_set(property_sets, name, value)
for _, call in ipairs(property_sets) do
if call.name == name and call.value == value then
@@ -631,15 +561,6 @@ local function fire_observer(recorded, name, value)
end
end
local function has_key_binding(recorded, keys, name)
for _, binding in ipairs(recorded.key_bindings or {}) do
if binding.keys == keys and binding.name == name then
return true
end
end
return false
end
local binary_path = "/tmp/subminer-binary"
local appimage_path = "/tmp/SubMiner.AppImage"
@@ -1325,7 +1246,6 @@ do
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
aniskip_enabled = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
@@ -1367,7 +1287,6 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
files = {
[binary_path] = true,
@@ -1404,14 +1323,11 @@ do
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
aniskip_enabled = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = media_path,
media_title = "Sample Show S01E01",
mal_lookup_stdout = "__MAL_FOUND__",
aniskip_stdout = "__ANISKIP_FOUND__",
files = {
[binary_path] = true,
},
@@ -1429,10 +1345,6 @@ do
count_property_set(recorded.property_sets, "pause", true) == 1,
"same-media reload should not re-arm pause-until-ready"
)
assert_true(
count_async_curl_for(recorded.async_calls, "api.aniskip.com") == 1,
"same-media reload should not repeat AniSkip lookup"
)
end
do
@@ -1535,7 +1447,6 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
media_title = "Random Movie",
files = {
@@ -1545,14 +1456,10 @@ do
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(not has_sync_command(recorded.sync_calls, "ps"), "file-loaded should avoid synchronous process checks")
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should avoid synchronous AniSkip network calls")
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should not perform synchronous network calls")
assert_true(
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
"file-loaded without SubMiner context should skip AniSkip MAL lookup"
)
assert_true(
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
"file-loaded without SubMiner context should skip AniSkip API lookup"
not has_async_command(recorded.async_calls, "curl"),
"file-loaded should not perform plugin-side AniSkip lookups (AniSkip now lives in the app)"
)
end
@@ -1574,75 +1481,12 @@ do
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for URL overlay-start AniSkip scenario: " .. tostring(err))
assert_true(recorded ~= nil, "plugin failed to load for URL overlay-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(find_start_call(recorded.async_calls) ~= nil, "URL auto-start should still invoke --start command")
assert_true(
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
"URL playback should skip AniSkip MAL lookup even after overlay-start"
)
assert_true(
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
"URL playback should skip AniSkip API lookup even after overlay-start"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
media_title = "Sample Show S01E01",
mal_lookup_stdout = "__MAL_FOUND__",
aniskip_stdout = "__ANISKIP_FOUND__",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for script-message AniSkip scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-aniskip-refresh"] ~= nil, "subminer-aniskip-refresh script message not registered")
recorded.script_messages["subminer-aniskip-refresh"]()
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "AniSkip refresh should not perform synchronous curl calls")
assert_true(has_async_command(recorded.async_calls, "curl"), "AniSkip refresh should perform async curl calls")
assert_true(
has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
"AniSkip refresh should perform MAL lookup even when app is not running"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
aniskip_enabled = "yes",
},
media_title = "Sample Show S01E01",
time_pos = 13,
mal_lookup_stdout = "__MAL_FOUND__",
aniskip_stdout = "__ANISKIP_FOUND__",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for default AniSkip keybinding scenario: " .. tostring(err))
assert_true(
has_key_binding(recorded, "TAB", "subminer-skip-intro"),
"default AniSkip keybinding should register TAB"
)
assert_true(
not has_key_binding(recorded, "y-k", "subminer-skip-intro-fallback"),
"default AniSkip keybinding should not also register legacy y-k fallback"
)
recorded.script_messages["subminer-aniskip-refresh"]()
fire_observer(recorded, "time-pos", 13)
assert_true(
has_osd_message(recorded.osd, "You can skip by pressing TAB"),
"default AniSkip prompt should mention TAB"
not has_async_command(recorded.async_calls, "curl"),
"URL playback should not trigger plugin-side network lookups"
)
end
@@ -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',
+1
View File
@@ -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' ||
+1
View File
@@ -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',
+18
View File
@@ -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'] }]);
});
+9
View File
@@ -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()) {
+4
View File
@@ -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);
},
};
}
-2
View File
@@ -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,
};
}
-6
View File
@@ -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
View File
@@ -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;
}
@@ -2,7 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
inferAniSkipMetadataForFile,
buildSubminerScriptOpts,
parseAniSkipGuessitJson,
resolveAniSkipMetadataForFile,
} from './aniskip-metadata';
@@ -98,6 +97,20 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
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) => {
@@ -133,6 +146,33 @@ test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async ()
);
});
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: [] }),
@@ -143,43 +183,3 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses'
},
);
});
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
const opts = buildSubminerScriptOpts(
'/tmp/SubMiner.AppImage',
'/tmp/subminer.sock',
{
title: "Frieren: Beyond Journey's End",
season: 1,
episode: 5,
source: 'guessit',
malId: 1234,
introStart: 30.5,
introEnd: 62,
lookupStatus: 'ready',
},
'debug',
);
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.doesNotMatch(opts, /subminer-log_level=/);
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/);
assert.match(opts, /subminer-aniskip_mal_id=1234/);
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
assert.match(opts, /subminer-aniskip_intro_end=62/);
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
assert.ok(payloadMatch !== null);
const encodedPayload = payloadMatch[1];
assert.ok(encodedPayload !== undefined);
assert.equal(encodedPayload.includes('%'), false);
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
const payload = JSON.parse(payloadJson);
assert.equal(payload.found, true);
const first = payload.results?.[0];
assert.equal(first.skip_type, 'op');
assert.equal(first.interval.start_time, 30.5);
assert.equal(first.interval.end_time, 62);
});
@@ -1,7 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import type { LogLevel } from './types.js';
import { commandExists } from './util.js';
export type AniSkipLookupStatus =
| 'ready'
@@ -63,7 +62,8 @@ const ROMAN_SEASON_ALIASES: Record<number, readonly string[]> = {
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 MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
const ANISKIP_USER_AGENT = 'SubMiner/ani-skip';
const ANISKIP_FETCH_TIMEOUT_MS = 8000;
const MAL_MATCH_STOPWORDS = new Set([
'the',
'this',
@@ -77,6 +77,27 @@ const MAL_MATCH_STOPWORDS = new Set([
'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);
@@ -103,6 +124,19 @@ function toPositiveNumber(value: unknown): number | null {
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()
@@ -227,10 +261,6 @@ function toMalSearchItems(payload: unknown): MalSearchResult[] {
return items;
}
function normalizeEpisodePayload(value: unknown): number | null {
return toPositiveNumber(value);
}
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
const parsed = payload as AniSkipPayloadResponse;
const results = Array.isArray(parsed?.results) ? parsed.results : null;
@@ -246,8 +276,8 @@ function parseAniSkipPayload(payload: unknown): { start: number; end: number } |
continue;
}
const interval = result.interval as AniSkipIntervalPayload;
const start = normalizeEpisodePayload(interval?.start_time);
const end = normalizeEpisodePayload(interval?.end_time);
const start = toNonNegativeNumber(interval?.start_time);
const end = toPositiveNumber(interval?.end_time);
if (start !== null && end !== null && end > start) {
return { start, end };
}
@@ -259,8 +289,9 @@ function parseAniSkipPayload(payload: unknown): { start: number; end: number } |
async function fetchJson<T>(url: string): Promise<T | null> {
const response = await fetch(url, {
headers: {
'User-Agent': MAL_USER_AGENT,
'User-Agent': ANISKIP_USER_AGENT,
},
signal: AbortSignal.timeout(ANISKIP_FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
try {
@@ -311,13 +342,17 @@ 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})$/,
];
for (const pattern of patterns) {
const match = baseName.match(pattern);
if (!match || !match[1]) continue;
const parsed = Number.parseInt(match[1], 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
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;
}
@@ -379,6 +414,7 @@ function cleanupTitle(value: string): string {
.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();
@@ -443,7 +479,7 @@ function defaultRunGuessit(mediaPath: string): string | null {
export function inferAniSkipMetadataForFile(
mediaPath: string,
deps: InferAniSkipDeps = { commandExists, runGuessit: defaultRunGuessit },
deps: InferAniSkipDeps = { commandExists: commandExistsOnPath, runGuessit: defaultRunGuessit },
): AniSkipMetadata {
if (deps.commandExists('guessit')) {
const stdout = deps.runGuessit(mediaPath);
@@ -527,79 +563,3 @@ export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<
};
}
}
function sanitizeScriptOptValue(value: string): string {
return value
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | null {
if (!aniSkipMetadata.malId || !aniSkipMetadata.introStart || !aniSkipMetadata.introEnd) {
return null;
}
if (aniSkipMetadata.introEnd <= aniSkipMetadata.introStart) {
return null;
}
const payload = {
found: true,
results: [
{
skip_type: 'op',
interval: {
start_time: aniSkipMetadata.introStart,
end_time: aniSkipMetadata.introEnd,
},
},
],
};
// mpv --script-opts treats `%` as an escape prefix, so URL-encoding can break parsing.
// Base64url stays script-opts-safe and is decoded by the plugin launcher payload parser.
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
aniSkipMetadata: AniSkipMetadata | null,
_logLevel: LogLevel = 'info',
extraParts: string[] = [],
): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
const parts = [
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
...extraParts.map(sanitizeScriptOptValue),
];
if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
}
if (aniSkipMetadata && aniSkipMetadata.season && aniSkipMetadata.season > 0) {
parts.push(`subminer-aniskip_season=${aniSkipMetadata.season}`);
}
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
}
if (aniSkipMetadata && aniSkipMetadata.malId && aniSkipMetadata.malId > 0) {
parts.push(`subminer-aniskip_mal_id=${aniSkipMetadata.malId}`);
}
if (aniSkipMetadata && aniSkipMetadata.introStart !== null && aniSkipMetadata.introStart > 0) {
parts.push(`subminer-aniskip_intro_start=${aniSkipMetadata.introStart}`);
}
if (aniSkipMetadata && aniSkipMetadata.introEnd !== null && aniSkipMetadata.introEnd > 0) {
parts.push(`subminer-aniskip_intro_end=${aniSkipMetadata.introEnd}`);
}
if (aniSkipMetadata?.lookupStatus) {
parts.push(
`subminer-aniskip_lookup_status=${sanitizeScriptOptValue(aniSkipMetadata.lookupStatus)}`,
);
}
const aniskipPayload = aniSkipMetadata ? buildLauncherAniSkipPayload(aniSkipMetadata) : null;
if (aniskipPayload) {
parts.push(`subminer-aniskip_payload=${sanitizeScriptOptValue(aniskipPayload)}`);
}
return parts.join(',');
}
+272
View File
@@ -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'],
]);
});
+305
View File
@@ -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}`,
];
}