diff --git a/README.md b/README.md index 71754a35..5ecc22f3 100644 --- a/README.md +++ b/README.md @@ -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 |
Platform-specific install commands diff --git a/changes/aniskip-app-runtime.md b/changes/aniskip-app-runtime.md new file mode 100644 index 00000000..04af8640 --- /dev/null +++ b/changes/aniskip-app-runtime.md @@ -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. diff --git a/changes/aniskip-timing.md b/changes/aniskip-timing.md new file mode 100644 index 00000000..8b949626 --- /dev/null +++ b/changes/aniskip-timing.md @@ -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`. diff --git a/config.example.jsonc b/config.example.jsonc index 53114269..52ba0736 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. // ========================================== diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 463ca476..dbe8fb44 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -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' }, ], }, diff --git a/docs-site/aniskip-integration.md b/docs-site/aniskip-integration.md new file mode 100644 index 00000000..0099619f --- /dev/null +++ b/docs-site/aniskip-integration.md @@ -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. diff --git a/docs-site/architecture.md b/docs-site/architecture.md index b9d5b4f4..ce1fe1d7 100644 --- a/docs-site/architecture.md +++ b/docs-site/architecture.md @@ -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 diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 8054152c..0f268f91 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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. diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index aa77af1f..ecb1dd5d 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -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. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 53114269..52ba0736 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. // ========================================== diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index 2fff4cc9..21b6da9e 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -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 diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index d208c901..bb9ab227 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -46,8 +46,6 @@ function createContext(overrides: Partial = {}): Launche autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, texthookerEnabled: false, - aniskipEnabled: true, - aniskipButtonKey: 'TAB', }, appPath: '/tmp/subminer.app', launcherJellyfinConfig: {}, diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 9f336da9..a7af0120 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -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[] = []; @@ -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[] = []; diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 1e5ce357..b9ed4419 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -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, }, ); diff --git a/launcher/config/mpv-config.ts b/launcher/config/mpv-config.ts index 507d3733..c38ffcc2 100644 --- a/launcher/config/mpv-config.ts +++ b/launcher/config/mpv-config.ts @@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record): 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), }; } diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts index c68badf2..2657d814 100644 --- a/launcher/config/plugin-runtime-config.ts +++ b/launcher/config/plugin-runtime-config.ts @@ -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; } diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 84a08fd7..9beb6b1c 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -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 { return { backend: 'x11', diff --git a/launcher/mpv.ts b/launcher/mpv.ts index a175f3cd..5dd8f42a 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -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}`); diff --git a/launcher/script-opts.test.ts b/launcher/script-opts.test.ts new file mode 100644 index 00000000..f93f09b4 --- /dev/null +++ b/launcher/script-opts.test.ts @@ -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/, + ); +}); diff --git a/launcher/script-opts.ts b/launcher/script-opts.ts new file mode 100644 index 00000000..322e85a2 --- /dev/null +++ b/launcher/script-opts.ts @@ -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(','); +} diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 18dbb149..f9327d91 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -559,7 +559,6 @@ test( socketPath: smokeCase.socketPath, autoStartSubMiner: true, pauseUntilOverlayReady: true, - aniskipEnabled: false, }, }), ); diff --git a/launcher/types.ts b/launcher/types.ts index 34229bc6..be8690c8 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -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 { diff --git a/plugin/subminer/aniskip.lua b/plugin/subminer/aniskip.lua deleted file mode 100644 index dacf512a..00000000 --- a/plugin/subminer/aniskip.lua +++ /dev/null @@ -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 diff --git a/plugin/subminer/aniskip_match.lua b/plugin/subminer/aniskip_match.lua deleted file mode 100644 index b33d8306..00000000 --- a/plugin/subminer/aniskip_match.lua +++ /dev/null @@ -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 diff --git a/plugin/subminer/bootstrap.lua b/plugin/subminer/bootstrap.lua index 992c8b5d..d6324ab8 100644 --- a/plugin/subminer/bootstrap.lua +++ b/plugin/subminer/bootstrap.lua @@ -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) diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 8714cc88..3632e622 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -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 { diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index ce9c1bd2..6fedae84 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -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) diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index b6c5c85d..163a5339 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -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") diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 85e05cec..0ae124e2 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -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, diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua index 6514aa6d..9b3ebd97 100644 --- a/plugin/subminer/ui.lua +++ b/plugin/subminer/ui.lua @@ -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 { diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index f4b4ef88..bf0be497 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -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 diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 2c805931..720c60ae 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -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', diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 9f98e237..a24573cb 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -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' || diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 90bec6b7..2f747dae 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -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', diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 40ccd2ee..97079b04 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -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'] }]); +}); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 217e3e87..2d29f806 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -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()) { diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 35ac1a0e..99263ac1 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -129,6 +129,7 @@ export interface MpvIpcClientEventMap { 'media-title-change': { title: string | null }; 'subtitle-metrics-change': { patch: Partial }; '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); + }, }; } diff --git a/src/main-entry-launch-config.ts b/src/main-entry-launch-config.ts index cec9959d..47f87f30 100644 --- a/src/main-entry-launch-config.ts +++ b/src/main-entry-launch-config.ts @@ -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, }; } diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 317df69a..01cb957e 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -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 }); diff --git a/src/main.ts b/src/main.ts index 2ce9e91e..8df702bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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; } diff --git a/launcher/aniskip-metadata.test.ts b/src/main/runtime/aniskip-metadata.test.ts similarity index 73% rename from launcher/aniskip-metadata.test.ts rename to src/main/runtime/aniskip-metadata.test.ts index 516fe427..b2fa55c4 100644 --- a/launcher/aniskip-metadata.test.ts +++ b/src/main/runtime/aniskip-metadata.test.ts @@ -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); -}); diff --git a/launcher/aniskip-metadata.ts b/src/main/runtime/aniskip-metadata.ts similarity index 79% rename from launcher/aniskip-metadata.ts rename to src/main/runtime/aniskip-metadata.ts index 99620249..a4c00a56 100644 --- a/launcher/aniskip-metadata.ts +++ b/src/main/runtime/aniskip-metadata.ts @@ -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 = { 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(url: string): Promise { 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(','); -} diff --git a/src/main/runtime/aniskip-runtime.test.ts b/src/main/runtime/aniskip-runtime.test.ts new file mode 100644 index 00000000..0934f309 --- /dev/null +++ b/src/main/runtime/aniskip-runtime.test.ts @@ -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 { + 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); + 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 { + 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'], + ]); +}); diff --git a/src/main/runtime/aniskip-runtime.ts b/src/main/runtime/aniskip-runtime.ts new file mode 100644 index 00000000..03fe41b5 --- /dev/null +++ b/src/main/runtime/aniskip-runtime.ts @@ -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; + sendMpvCommand: (command: unknown[]) => void; + requestMpvProperty: (name: string) => Promise; + 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(); + + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index bef56292..e9a81ea2 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -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); } diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index e38803c3..9a4212f8 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -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?.(), }); } diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index 3ac69970..8f1fc263 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -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', () => { diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index f1255667..814efc38 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -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', }, ); diff --git a/src/shared/subminer-plugin-script-opts.ts b/src/shared/subminer-plugin-script-opts.ts index 19bd4b67..3aacdf18 100644 --- a/src/shared/subminer-plugin-script-opts.ts +++ b/src/shared/subminer-plugin-script-opts.ts @@ -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}`, ]; }