mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
feat(aniskip): move intro detection from mpv plugin to app runtime (#117)
This commit is contained in:
@@ -121,6 +121,7 @@ Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but
|
|||||||
| yt-dlp | Optional | YouTube playback |
|
| yt-dlp | Optional | YouTube playback |
|
||||||
| fzf / rofi | Optional | Video picker in the launcher |
|
| fzf / rofi | Optional | Video picker in the launcher |
|
||||||
| alass / ffsubsync | Optional | Subtitle sync |
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
|
| guessit | Optional | Better anime title and episode detection |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Platform-specific install commands</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -634,8 +634,8 @@
|
|||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"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
|
"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.
|
"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
|
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
|
||||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
|
||||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'YouTube', link: '/youtube-integration' },
|
{ text: 'YouTube', link: '/youtube-integration' },
|
||||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||||
{ text: 'AniList', link: '/anilist-integration' },
|
{ text: 'AniList', link: '/anilist-integration' },
|
||||||
|
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -30,7 +30,7 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
|||||||
plugin/
|
plugin/
|
||||||
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
|
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
|
||||||
# state · messages · hover · ui · options · environment · log
|
# state · messages · hover · ui · options · environment · log
|
||||||
# binary · aniskip · aniskip_match)
|
# binary)
|
||||||
src/
|
src/
|
||||||
ai/ # AI translation provider utilities (client, config)
|
ai/ # AI translation provider utilities (client, config)
|
||||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
||||||
@@ -130,7 +130,7 @@ src/renderer/
|
|||||||
### Launcher + Plugin Runtimes
|
### 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.
|
- `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
|
## Flow Diagram
|
||||||
|
|
||||||
|
|||||||
@@ -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`) |
|
| `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`) |
|
| `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: `""`) |
|
| `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`) |
|
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||||
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
| `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.
|
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||||
|
|
||||||
|
|||||||
+4
-19
@@ -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 |
|
| `v` | Toggle primary subtitle bar visibility |
|
||||||
| `TAB` (default) | Skip intro (AniSkip) |
|
| `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.
|
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-restart
|
||||||
script-message subminer-status
|
script-message subminer-status
|
||||||
script-message subminer-autoplay-ready
|
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:
|
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.
|
`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.
|
`--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
|
## 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).
|
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).
|
- **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).
|
- **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.
|
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
||||||
|
|||||||
@@ -634,8 +634,8 @@
|
|||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"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
|
"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.
|
"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
|
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
|
||||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
|
||||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -265,10 +265,10 @@ script-message subminer-options
|
|||||||
script-message subminer-restart
|
script-message subminer-restart
|
||||||
script-message subminer-status
|
script-message subminer-status
|
||||||
script-message subminer-autoplay-ready
|
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:
|
The start command also accepts inline overrides:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -283,7 +283,7 @@ Examples:
|
|||||||
|
|
||||||
- send `subminer-start` after your own media-selection script chooses a file
|
- send `subminer-start` after your own media-selection script chooses a file
|
||||||
- send `subminer-status` before running follow-up automation
|
- 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
|
#### Build a launcher wrapper
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
|||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
},
|
},
|
||||||
appPath: '/tmp/subminer.app',
|
appPath: '/tmp/subminer.app',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
|
|||||||
@@ -83,8 +83,6 @@ function createContext(): LauncherCommandContext {
|
|||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
},
|
},
|
||||||
appPath: '/tmp/SubMiner.AppImage',
|
appPath: '/tmp/SubMiner.AppImage',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
@@ -210,8 +208,6 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
|||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
};
|
};
|
||||||
const appPath = context.appPath ?? '';
|
const appPath = context.appPath ?? '';
|
||||||
state.appPath = appPath;
|
state.appPath = appPath;
|
||||||
@@ -273,8 +269,6 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
|||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: true,
|
texthookerEnabled: true,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
};
|
};
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||||
@@ -342,8 +336,6 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
|||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: true,
|
texthookerEnabled: true,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
};
|
};
|
||||||
let availabilityConfigDir: string | undefined;
|
let availabilityConfigDir: string | undefined;
|
||||||
let overlayConfigDir: 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,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: true,
|
texthookerEnabled: true,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
};
|
};
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
|||||||
autoStartSubMiner: false,
|
autoStartSubMiner: false,
|
||||||
pauseUntilOverlayReady: false,
|
pauseUntilOverlayReady: false,
|
||||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
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.autoStartSubMiner, false);
|
||||||
assert.equal(parsed.pauseUntilOverlayReady, false);
|
assert.equal(parsed.pauseUntilOverlayReady, false);
|
||||||
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
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', () => {
|
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
|
||||||
@@ -138,8 +134,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
|
|||||||
autoStartSubMiner: true,
|
autoStartSubMiner: true,
|
||||||
pauseUntilOverlayReady: true,
|
pauseUntilOverlayReady: true,
|
||||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
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.autoStartPauseUntilReady, true);
|
||||||
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||||
assert.equal(parsed.texthookerEnabled, false);
|
assert.equal(parsed.texthookerEnabled, false);
|
||||||
assert.equal(parsed.aniskipEnabled, false);
|
|
||||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
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.autoStartVisibleOverlay, false);
|
||||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||||
assert.equal(parsed.texthookerEnabled, false);
|
assert.equal(parsed.texthookerEnabled, false);
|
||||||
assert.equal(parsed.aniskipEnabled, true);
|
|
||||||
assert.equal(parsed.aniskipButtonKey, 'TAB');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
||||||
@@ -176,8 +166,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
|||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: false,
|
|
||||||
aniskipButtonKey: 'F8',
|
|
||||||
},
|
},
|
||||||
'/fallback/SubMiner.AppImage',
|
'/fallback/SubMiner.AppImage',
|
||||||
),
|
),
|
||||||
@@ -189,8 +177,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
|||||||
'subminer-auto_start_visible_overlay=no',
|
'subminer-auto_start_visible_overlay=no',
|
||||||
'subminer-auto_start_pause_until_ready=yes',
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
'subminer-texthooker_enabled=no',
|
'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,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: false,
|
|
||||||
aniskipButtonKey: 'F8,\nF9',
|
|
||||||
},
|
},
|
||||||
'/fallback/SubMiner.AppImage',
|
'/fallback/SubMiner.AppImage',
|
||||||
),
|
),
|
||||||
@@ -219,8 +203,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
|||||||
'subminer-auto_start_visible_overlay=no',
|
'subminer-auto_start_visible_overlay=no',
|
||||||
'subminer-auto_start_pause_until_ready=yes',
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
'subminer-texthooker_enabled=no',
|
'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,
|
pauseUntilOverlayReady: undefined,
|
||||||
subminerBinaryPath: undefined,
|
subminerBinaryPath: undefined,
|
||||||
profile: 'anime',
|
profile: 'anime',
|
||||||
aniskipEnabled: undefined,
|
|
||||||
aniskipButtonKey: undefined,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
|||||||
pauseUntilOverlayReady:
|
pauseUntilOverlayReady:
|
||||||
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
||||||
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
|
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
|
||||||
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
|
|
||||||
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export function parsePluginRuntimeConfigFromMainConfig(
|
|||||||
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||||
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||||
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
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(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
logLevel,
|
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;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import {
|
|||||||
runAppCommandCaptureOutput,
|
runAppCommandCaptureOutput,
|
||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
resolveLauncherRuntimePluginPlan,
|
resolveLauncherRuntimePluginPlan,
|
||||||
shouldResolveAniSkipMetadataForLaunch,
|
|
||||||
shouldResolveAniSkipMetadata,
|
|
||||||
stopOverlay,
|
stopOverlay,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
state,
|
state,
|
||||||
@@ -388,31 +386,12 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
|
|||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
|
['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', () => {
|
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||||
@@ -565,20 +544,6 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shouldResolveAniSkipMetadata skips URL and YouTube-preloaded playback', () => {
|
|
||||||
assert.equal(shouldResolveAniSkipMetadata('/media/show.mkv', 'file'), true);
|
|
||||||
assert.equal(
|
|
||||||
shouldResolveAniSkipMetadata('https://www.youtube.com/watch?v=test123', 'url'),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
shouldResolveAniSkipMetadata('/tmp/video123.webm', 'file', {
|
|
||||||
primaryPath: '/tmp/video123.ja.srt',
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeArgs(overrides: Partial<Args> = {}): Args {
|
function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||||
return {
|
return {
|
||||||
backend: 'x11',
|
backend: 'x11',
|
||||||
|
|||||||
+3
-50
@@ -27,7 +27,7 @@ import {
|
|||||||
shouldForwardLogLevel,
|
shouldForwardLogLevel,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.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 { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
||||||
import { nowMs } from './time.js';
|
import { nowMs } from './time.js';
|
||||||
import {
|
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 = {
|
type StartMpvOptions = {
|
||||||
startPaused?: boolean;
|
startPaused?: boolean;
|
||||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||||
@@ -844,18 +830,6 @@ type StartMpvOptions = {
|
|||||||
runtimePluginConfig?: PluginRuntimeConfig;
|
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(
|
export function buildRuntimeExtraScriptOptParts(
|
||||||
target: string,
|
target: string,
|
||||||
targetKind: 'file' | 'url',
|
targetKind: 'file' | 'url',
|
||||||
@@ -946,29 +920,14 @@ export async function startMpv(
|
|||||||
if (options?.startPaused) {
|
if (options?.startPaused) {
|
||||||
mpvArgs.push('--pause=yes');
|
mpvArgs.push('--pause=yes');
|
||||||
}
|
}
|
||||||
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
|
|
||||||
target,
|
|
||||||
targetKind,
|
|
||||||
preloadedSubtitles,
|
|
||||||
options?.runtimePluginConfig,
|
|
||||||
)
|
|
||||||
? await resolveAniSkipMetadataForFile(target)
|
|
||||||
: null;
|
|
||||||
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
|
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
|
||||||
const runtimeScriptOpts = options?.runtimePluginConfig
|
const runtimeScriptOpts = options?.runtimePluginConfig
|
||||||
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
|
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
|
||||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, [
|
||||||
...runtimeScriptOpts,
|
...runtimeScriptOpts,
|
||||||
...extraScriptOpts,
|
...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(`--script-opts=${scriptOpts}`);
|
||||||
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||||
|
|
||||||
@@ -1701,13 +1660,7 @@ export function launchMpvIdleDetached(
|
|||||||
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
|
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
|
||||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||||
mpvArgs.push(
|
mpvArgs.push(
|
||||||
`--script-opts=${buildSubminerScriptOpts(
|
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, runtimeScriptOpts)}`,
|
||||||
appPath,
|
|
||||||
socketPath,
|
|
||||||
null,
|
|
||||||
args.logLevel,
|
|
||||||
runtimeScriptOpts,
|
|
||||||
)}`,
|
|
||||||
);
|
);
|
||||||
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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(',');
|
||||||
|
}
|
||||||
@@ -559,7 +559,6 @@ test(
|
|||||||
socketPath: smokeCase.socketPath,
|
socketPath: smokeCase.socketPath,
|
||||||
autoStartSubMiner: true,
|
autoStartSubMiner: true,
|
||||||
pauseUntilOverlayReady: true,
|
pauseUntilOverlayReady: true,
|
||||||
aniskipEnabled: false,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -191,8 +191,6 @@ export interface LauncherMpvConfig {
|
|||||||
autoStartSubMiner?: boolean;
|
autoStartSubMiner?: boolean;
|
||||||
pauseUntilOverlayReady?: boolean;
|
pauseUntilOverlayReady?: boolean;
|
||||||
subminerBinaryPath?: string;
|
subminerBinaryPath?: string;
|
||||||
aniskipEnabled?: boolean;
|
|
||||||
aniskipButtonKey?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LauncherLoggingConfig {
|
export interface LauncherLoggingConfig {
|
||||||
@@ -210,8 +208,6 @@ export interface PluginRuntimeConfig {
|
|||||||
autoStartVisibleOverlay: boolean;
|
autoStartVisibleOverlay: boolean;
|
||||||
autoStartPauseUntilReady: boolean;
|
autoStartPauseUntilReady: boolean;
|
||||||
texthookerEnabled: boolean;
|
texthookerEnabled: boolean;
|
||||||
aniskipEnabled: boolean;
|
|
||||||
aniskipButtonKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandExecOptions {
|
export interface CommandExecOptions {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -56,9 +56,6 @@ function M.init()
|
|||||||
ctx.binary = make_lazy_proxy("binary", function()
|
ctx.binary = make_lazy_proxy("binary", function()
|
||||||
return require("binary").create(ctx)
|
return require("binary").create(ctx)
|
||||||
end)
|
end)
|
||||||
ctx.aniskip = make_lazy_proxy("aniskip", function()
|
|
||||||
return require("aniskip").create(ctx)
|
|
||||||
end)
|
|
||||||
ctx.hover = make_lazy_proxy("hover", function()
|
ctx.hover = make_lazy_proxy("hover", function()
|
||||||
return require("hover").create(ctx)
|
return require("hover").create(ctx)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ function M.create(ctx)
|
|||||||
local state = ctx.state
|
local state = ctx.state
|
||||||
local options_helper = ctx.options_helper
|
local options_helper = ctx.options_helper
|
||||||
local process = ctx.process
|
local process = ctx.process
|
||||||
local aniskip = ctx.aniskip
|
|
||||||
local hover = ctx.hover
|
local hover = ctx.hover
|
||||||
local subminer_log = ctx.log.subminer_log
|
local subminer_log = ctx.log.subminer_log
|
||||||
local show_osd = ctx.log.show_osd
|
local show_osd = ctx.log.show_osd
|
||||||
@@ -52,13 +51,6 @@ function M.create(ctx)
|
|||||||
return reason == "reload" or reason == "redirect"
|
return reason == "reload" or reason == "redirect"
|
||||||
end
|
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 function clear_pending_visible_overlay_hide()
|
||||||
local timer = state.pending_visible_overlay_hide_timer
|
local timer = state.pending_visible_overlay_hide_timer
|
||||||
if timer and timer.kill then
|
if timer and timer.kill then
|
||||||
@@ -159,7 +151,6 @@ function M.create(ctx)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not resolve_auto_start_enabled() then
|
if not resolve_auto_start_enabled() then
|
||||||
schedule_aniskip_fetch("file-loaded", 0)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -178,7 +169,6 @@ function M.create(ctx)
|
|||||||
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
|
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
|
||||||
.. ")"
|
.. ")"
|
||||||
)
|
)
|
||||||
schedule_aniskip_fetch("file-loaded", 0)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -187,8 +177,6 @@ function M.create(ctx)
|
|||||||
socket_path = opts.socket_path,
|
socket_path = opts.socket_path,
|
||||||
rearm_pause_until_ready = should_rearm_pause_until_ready(same_media_loaded),
|
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
|
end
|
||||||
|
|
||||||
local function on_start_file()
|
local function on_start_file()
|
||||||
@@ -267,7 +255,6 @@ function M.create(ctx)
|
|||||||
local preserve_active_auto_start_gate = (
|
local preserve_active_auto_start_gate = (
|
||||||
state.overlay_running and state.auto_play_ready_gate_armed and should_auto_start and has_matching_socket
|
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
|
if not preserve_active_auto_start_gate then
|
||||||
process.disarm_auto_play_ready_gate()
|
process.disarm_auto_play_ready_gate()
|
||||||
end
|
end
|
||||||
@@ -283,12 +270,10 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
refresh_managed_subtitle_autoloading()
|
refresh_managed_subtitle_autoloading()
|
||||||
schedule_aniskip_fetch("file-loaded", 0)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function on_shutdown()
|
local function on_shutdown()
|
||||||
next_auto_start_retry_generation()
|
next_auto_start_retry_generation()
|
||||||
aniskip.clear_aniskip_state()
|
|
||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
process.disarm_auto_play_ready_gate()
|
process.disarm_auto_play_ready_gate()
|
||||||
clear_pending_visible_overlay_hide()
|
clear_pending_visible_overlay_hide()
|
||||||
@@ -334,22 +319,12 @@ function M.create(ctx)
|
|||||||
mp.register_event("shutdown", function()
|
mp.register_event("shutdown", function()
|
||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
end)
|
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()
|
mp.add_hook("on_unload", 10, function()
|
||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
aniskip.clear_aniskip_state()
|
|
||||||
end)
|
end)
|
||||||
mp.observe_property("sub-start", "native", function()
|
mp.observe_property("sub-start", "native", function()
|
||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
end)
|
end)
|
||||||
mp.observe_property("time-pos", "number", function()
|
|
||||||
aniskip.update_intro_button_visibility()
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ local M = {}
|
|||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
local process = ctx.process
|
local process = ctx.process
|
||||||
local aniskip = ctx.aniskip
|
|
||||||
local hover = ctx.hover
|
local hover = ctx.hover
|
||||||
local ui = ctx.ui
|
local ui = ctx.ui
|
||||||
local state = ctx.state
|
local state = ctx.state
|
||||||
@@ -43,12 +42,6 @@ function M.create(ctx)
|
|||||||
mp.register_script_message("subminer-autoplay-ready", function()
|
mp.register_script_message("subminer-autoplay-ready", function()
|
||||||
process.notify_auto_play_ready()
|
process.notify_auto_play_ready()
|
||||||
end)
|
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)
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
hover.handle_hover_message(payload_json)
|
hover.handle_hover_message(payload_json)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
local M = {}
|
local M = {}
|
||||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
|
||||||
|
|
||||||
local function normalize_socket_path_option(socket_path, default_socket_path)
|
local function normalize_socket_path_option(socket_path, default_socket_path)
|
||||||
if type(default_socket_path) ~= "string" then
|
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,
|
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||||
osd_messages = true,
|
osd_messages = true,
|
||||||
log_level = "info",
|
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")
|
options_lib.read_options(opts, "subminer")
|
||||||
|
|||||||
@@ -18,17 +18,6 @@ function M.new()
|
|||||||
clear_timer = nil,
|
clear_timer = nil,
|
||||||
last_hover_update_ts = 0,
|
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_gate_armed = false,
|
||||||
auto_play_ready_should_resume_playback = false,
|
auto_play_ready_should_resume_playback = false,
|
||||||
auto_play_ready_timeout = nil,
|
auto_play_ready_timeout = nil,
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
local M = {}
|
local M = {}
|
||||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
|
||||||
local LEGACY_ANISKIP_BUTTON_KEY = "y-k"
|
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
local input = ctx.input
|
local input = ctx.input
|
||||||
local opts = ctx.opts
|
|
||||||
local process = ctx.process
|
local process = ctx.process
|
||||||
local aniskip = ctx.aniskip
|
|
||||||
local subminer_log = ctx.log.subminer_log
|
local subminer_log = ctx.log.subminer_log
|
||||||
local show_osd = ctx.log.show_osd
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
@@ -99,19 +95,6 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
process.run_control_command_async("open-session-help")
|
process.run_control_command_async("open-session-help")
|
||||||
end)
|
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
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -87,13 +87,6 @@ local function run_plugin_scenario(config)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
if args[1] == "curl" then
|
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 = "" }
|
return { status = 0, stdout = "{}", stderr = "" }
|
||||||
end
|
end
|
||||||
return { status = 0, stdout = "", stderr = "" }
|
return { status = 0, stdout = "", stderr = "" }
|
||||||
@@ -108,15 +101,8 @@ local function run_plugin_scenario(config)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if args[1] == "curl" then
|
if args[1] == "curl" then
|
||||||
local url = args[#args] or ""
|
callback(true, { status = 0, stdout = "{}", stderr = "" }, nil)
|
||||||
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
return
|
||||||
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
|
|
||||||
end
|
end
|
||||||
for _, value in ipairs(args) do
|
for _, value in ipairs(args) do
|
||||||
if value == "--app-ping" then
|
if value == "--app-ping" then
|
||||||
@@ -263,34 +249,6 @@ local function run_plugin_scenario(config)
|
|||||||
amount = 125,
|
amount = 125,
|
||||||
}, nil
|
}, nil
|
||||||
end
|
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
|
return {}, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -311,7 +269,6 @@ local function run_plugin_scenario(config)
|
|||||||
package.loaded["process"] = nil
|
package.loaded["process"] = nil
|
||||||
package.loaded["state"] = nil
|
package.loaded["state"] = nil
|
||||||
package.loaded["ui"] = nil
|
package.loaded["ui"] = nil
|
||||||
package.loaded["aniskip"] = nil
|
|
||||||
_G.__subminer_plugin_bootstrapped = nil
|
_G.__subminer_plugin_bootstrapped = nil
|
||||||
local original_package_config = package.config
|
local original_package_config = package.config
|
||||||
if config.platform == "windows" then
|
if config.platform == "windows" then
|
||||||
@@ -505,33 +462,6 @@ local function has_async_command(async_calls, executable)
|
|||||||
return false
|
return false
|
||||||
end
|
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)
|
local function has_property_set(property_sets, name, value)
|
||||||
for _, call in ipairs(property_sets) do
|
for _, call in ipairs(property_sets) do
|
||||||
if call.name == name and call.value == value then
|
if call.name == name and call.value == value then
|
||||||
@@ -631,15 +561,6 @@ local function fire_observer(recorded, name, value)
|
|||||||
end
|
end
|
||||||
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 binary_path = "/tmp/subminer-binary"
|
||||||
local appimage_path = "/tmp/SubMiner.AppImage"
|
local appimage_path = "/tmp/SubMiner.AppImage"
|
||||||
|
|
||||||
@@ -1325,7 +1246,6 @@ do
|
|||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
auto_start_visible_overlay = "yes",
|
auto_start_visible_overlay = "yes",
|
||||||
auto_start_pause_until_ready = "yes",
|
auto_start_pause_until_ready = "yes",
|
||||||
aniskip_enabled = "yes",
|
|
||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
@@ -1367,7 +1287,6 @@ do
|
|||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "no",
|
auto_start = "no",
|
||||||
aniskip_enabled = "yes",
|
|
||||||
},
|
},
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
@@ -1404,14 +1323,11 @@ do
|
|||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
auto_start_visible_overlay = "yes",
|
auto_start_visible_overlay = "yes",
|
||||||
auto_start_pause_until_ready = "yes",
|
auto_start_pause_until_ready = "yes",
|
||||||
aniskip_enabled = "yes",
|
|
||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
path = media_path,
|
path = media_path,
|
||||||
media_title = "Sample Show S01E01",
|
media_title = "Sample Show S01E01",
|
||||||
mal_lookup_stdout = "__MAL_FOUND__",
|
|
||||||
aniskip_stdout = "__ANISKIP_FOUND__",
|
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
},
|
},
|
||||||
@@ -1429,10 +1345,6 @@ do
|
|||||||
count_property_set(recorded.property_sets, "pause", true) == 1,
|
count_property_set(recorded.property_sets, "pause", true) == 1,
|
||||||
"same-media reload should not re-arm pause-until-ready"
|
"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
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -1535,7 +1447,6 @@ do
|
|||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "no",
|
auto_start = "no",
|
||||||
aniskip_enabled = "yes",
|
|
||||||
},
|
},
|
||||||
media_title = "Random Movie",
|
media_title = "Random Movie",
|
||||||
files = {
|
files = {
|
||||||
@@ -1545,14 +1456,10 @@ do
|
|||||||
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
|
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
|
||||||
fire_event(recorded, "file-loaded")
|
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, "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(
|
assert_true(
|
||||||
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
not has_async_command(recorded.async_calls, "curl"),
|
||||||
"file-loaded without SubMiner context should skip AniSkip MAL lookup"
|
"file-loaded should not perform plugin-side AniSkip lookups (AniSkip now lives in the app)"
|
||||||
)
|
|
||||||
assert_true(
|
|
||||||
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
|
|
||||||
"file-loaded without SubMiner context should skip AniSkip API lookup"
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1574,75 +1481,12 @@ do
|
|||||||
[binary_path] = true,
|
[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")
|
fire_event(recorded, "file-loaded")
|
||||||
assert_true(find_start_call(recorded.async_calls) ~= nil, "URL auto-start should still invoke --start command")
|
assert_true(find_start_call(recorded.async_calls) ~= nil, "URL auto-start should still invoke --start command")
|
||||||
assert_true(
|
assert_true(
|
||||||
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
not has_async_command(recorded.async_calls, "curl"),
|
||||||
"URL playback should skip AniSkip MAL lookup even after overlay-start"
|
"URL playback should not trigger plugin-side network lookups"
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -496,13 +496,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
path: 'mpv.aniskipEnabled',
|
path: 'mpv.aniskipEnabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
defaultValue: defaultConfig.mpv.aniskipEnabled,
|
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',
|
path: 'mpv.aniskipButtonKey',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
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',
|
path: 'jellyfin.enabled',
|
||||||
|
|||||||
@@ -680,6 +680,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.fields.miscInfo' ||
|
path === 'ankiConnect.fields.miscInfo' ||
|
||||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||||
|
path === 'mpv.aniskipEnabled' ||
|
||||||
path === 'mpv.aniskipButtonKey' ||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
path === 'stats.markWatchedKey' ||
|
path === 'stats.markWatchedKey' ||
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
|
|||||||
|
|
||||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||||
'secondarySub.defaultMode',
|
'secondarySub.defaultMode',
|
||||||
|
'mpv.aniskipEnabled',
|
||||||
'mpv.aniskipButtonKey',
|
'mpv.aniskipButtonKey',
|
||||||
'ankiConnect.ai.enabled',
|
'ankiConnect.ai.enabled',
|
||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
|
|||||||
@@ -350,3 +350,21 @@ test('visibility and boolean parsers handle text values', () => {
|
|||||||
assert.equal(asBoolean('yes', false), true);
|
assert.equal(asBoolean('yes', false), true);
|
||||||
assert.equal(asBoolean('0', true), false);
|
assert.equal(asBoolean('0', true), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage emits client-message string args', async () => {
|
||||||
|
const received: Array<{ args: string[] }> = [];
|
||||||
|
const { deps } = createDeps({
|
||||||
|
emitClientMessage: (payload) => {
|
||||||
|
received.push(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'client-message', args: ['subminer-skip-intro', 42, 'extra'] },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await dispatchMpvProtocolMessage({ event: 'client-message', args: [] }, deps);
|
||||||
|
await dispatchMpvProtocolMessage({ event: 'client-message' }, deps);
|
||||||
|
|
||||||
|
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type MpvMessage = {
|
|||||||
event?: string;
|
event?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
|
args?: unknown;
|
||||||
request_id?: number;
|
request_id?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
@@ -94,6 +95,7 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
restorePreviousSecondarySubVisibility: () => void;
|
restorePreviousSecondarySubVisibility: () => void;
|
||||||
shouldQuitOnMpvShutdown: () => boolean;
|
shouldQuitOnMpvShutdown: () => boolean;
|
||||||
requestAppQuit: () => void;
|
requestAppQuit: () => void;
|
||||||
|
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtitleTrackCandidate = {
|
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') {
|
} else if (msg.event === 'shutdown') {
|
||||||
deps.restorePreviousSecondarySubVisibility();
|
deps.restorePreviousSecondarySubVisibility();
|
||||||
if (deps.shouldQuitOnMpvShutdown()) {
|
if (deps.shouldQuitOnMpvShutdown()) {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export interface MpvIpcClientEventMap {
|
|||||||
'media-title-change': { title: string | null };
|
'media-title-change': { title: string | null };
|
||||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||||
'secondary-subtitle-visibility': { visible: boolean };
|
'secondary-subtitle-visibility': { visible: boolean };
|
||||||
|
'client-message': { args: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||||
@@ -491,6 +492,9 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
},
|
},
|
||||||
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
|
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||||
requestAppQuit: () => this.deps.requestAppQuit?.(),
|
requestAppQuit: () => this.deps.requestAppQuit?.(),
|
||||||
|
emitClientMessage: (payload) => {
|
||||||
|
this.emit('client-message', payload);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export function buildWindowsMpvPluginRuntimeConfig(
|
|||||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
|
||||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,8 +326,6 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script
|
|||||||
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
|
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
|
||||||
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
|
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
|
||||||
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
|
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||||
aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled,
|
|
||||||
aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
@@ -359,8 +357,6 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
|||||||
autoStartSubMiner: false,
|
autoStartSubMiner: false,
|
||||||
pauseUntilOverlayReady: false,
|
pauseUntilOverlayReady: false,
|
||||||
subminerBinaryPath: 'C:\\SubMiner\\Custom.exe',
|
subminerBinaryPath: 'C:\\SubMiner\\Custom.exe',
|
||||||
aniskipEnabled: false,
|
|
||||||
aniskipButtonKey: 'F8',
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -382,8 +378,6 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
|||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
texthookerEnabled: true,
|
texthookerEnabled: true,
|
||||||
aniskipEnabled: false,
|
|
||||||
aniskipButtonKey: 'F8',
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
|||||||
+34
-2
@@ -33,6 +33,8 @@ import {
|
|||||||
} from 'electron';
|
} from 'electron';
|
||||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
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 { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||||
import {
|
import {
|
||||||
@@ -1468,8 +1470,6 @@ function getMpvPluginRuntimeConfig() {
|
|||||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
|
||||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2231,6 +2231,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
|||||||
setLogFileToggles: (files) => {
|
setLogFileToggles: (files) => {
|
||||||
setLogFileToggles(files);
|
setLogFileToggles(files);
|
||||||
},
|
},
|
||||||
|
applyAniSkipConfig: () => {
|
||||||
|
aniSkipRuntime.applyConfigChange();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
|
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
|
||||||
@@ -5401,6 +5404,31 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease(
|
|||||||
});
|
});
|
||||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
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 {
|
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||||
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||||
client.on('connection-change', ({ connected }) => {
|
client.on('connection-change', ({ connected }) => {
|
||||||
@@ -5414,6 +5442,10 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
|||||||
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
||||||
overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
|
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;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
inferAniSkipMetadataForFile,
|
inferAniSkipMetadataForFile,
|
||||||
buildSubminerScriptOpts,
|
|
||||||
parseAniSkipGuessitJson,
|
parseAniSkipGuessitJson,
|
||||||
resolveAniSkipMetadataForFile,
|
resolveAniSkipMetadataForFile,
|
||||||
} from './aniskip-metadata';
|
} from './aniskip-metadata';
|
||||||
@@ -98,6 +97,20 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
|
|||||||
assert.equal(parsed.source, 'fallback');
|
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 () => {
|
test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
|
||||||
await withMockFetch(
|
await withMockFetch(
|
||||||
async (input) => {
|
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 () => {
|
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
|
||||||
await withMockFetch(
|
await withMockFetch(
|
||||||
async () => makeMockResponse({ categories: [] }),
|
async () => makeMockResponse({ categories: [] }),
|
||||||
@@ -143,43 +183,3 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses'
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
|
||||||
const opts = buildSubminerScriptOpts(
|
|
||||||
'/tmp/SubMiner.AppImage',
|
|
||||||
'/tmp/subminer.sock',
|
|
||||||
{
|
|
||||||
title: "Frieren: Beyond Journey's End",
|
|
||||||
season: 1,
|
|
||||||
episode: 5,
|
|
||||||
source: 'guessit',
|
|
||||||
malId: 1234,
|
|
||||||
introStart: 30.5,
|
|
||||||
introEnd: 62,
|
|
||||||
lookupStatus: 'ready',
|
|
||||||
},
|
|
||||||
'debug',
|
|
||||||
);
|
|
||||||
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
|
||||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
|
||||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
|
||||||
assert.doesNotMatch(opts, /subminer-log_level=/);
|
|
||||||
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
|
||||||
assert.match(opts, /subminer-aniskip_season=1/);
|
|
||||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
|
||||||
assert.match(opts, /subminer-aniskip_mal_id=1234/);
|
|
||||||
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
|
|
||||||
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
|
||||||
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
|
||||||
assert.ok(payloadMatch !== null);
|
|
||||||
const encodedPayload = payloadMatch[1];
|
|
||||||
assert.ok(encodedPayload !== undefined);
|
|
||||||
assert.equal(encodedPayload.includes('%'), false);
|
|
||||||
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
|
|
||||||
const payload = JSON.parse(payloadJson);
|
|
||||||
assert.equal(payload.found, true);
|
|
||||||
const first = payload.results?.[0];
|
|
||||||
assert.equal(first.skip_type, 'op');
|
|
||||||
assert.equal(first.interval.start_time, 30.5);
|
|
||||||
assert.equal(first.interval.end_time, 62);
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import type { LogLevel } from './types.js';
|
|
||||||
import { commandExists } from './util.js';
|
|
||||||
|
|
||||||
export type AniSkipLookupStatus =
|
export type AniSkipLookupStatus =
|
||||||
| 'ready'
|
| 'ready'
|
||||||
@@ -63,7 +62,8 @@ const ROMAN_SEASON_ALIASES: Record<number, readonly string[]> = {
|
|||||||
|
|
||||||
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
||||||
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
|
const ANISKIP_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([
|
const MAL_MATCH_STOPWORDS = new Set([
|
||||||
'the',
|
'the',
|
||||||
'this',
|
'this',
|
||||||
@@ -77,6 +77,27 @@ const MAL_MATCH_STOPWORDS = new Set([
|
|||||||
'and',
|
'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 {
|
function toPositiveInt(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
return Math.floor(value);
|
return Math.floor(value);
|
||||||
@@ -103,6 +124,19 @@ function toPositiveNumber(value: unknown): number | null {
|
|||||||
return 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 {
|
function normalizeForMatch(value: string): string {
|
||||||
return value
|
return value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -227,10 +261,6 @@ function toMalSearchItems(payload: unknown): MalSearchResult[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEpisodePayload(value: unknown): number | null {
|
|
||||||
return toPositiveNumber(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
|
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
|
||||||
const parsed = payload as AniSkipPayloadResponse;
|
const parsed = payload as AniSkipPayloadResponse;
|
||||||
const results = Array.isArray(parsed?.results) ? parsed.results : null;
|
const results = Array.isArray(parsed?.results) ? parsed.results : null;
|
||||||
@@ -246,8 +276,8 @@ function parseAniSkipPayload(payload: unknown): { start: number; end: number } |
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const interval = result.interval as AniSkipIntervalPayload;
|
const interval = result.interval as AniSkipIntervalPayload;
|
||||||
const start = normalizeEpisodePayload(interval?.start_time);
|
const start = toNonNegativeNumber(interval?.start_time);
|
||||||
const end = normalizeEpisodePayload(interval?.end_time);
|
const end = toPositiveNumber(interval?.end_time);
|
||||||
if (start !== null && end !== null && end > start) {
|
if (start !== null && end !== null && end > start) {
|
||||||
return { start, end };
|
return { start, end };
|
||||||
}
|
}
|
||||||
@@ -259,8 +289,9 @@ function parseAniSkipPayload(payload: unknown): { start: number; end: number } |
|
|||||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': MAL_USER_AGENT,
|
'User-Agent': ANISKIP_USER_AGENT,
|
||||||
},
|
},
|
||||||
|
signal: AbortSignal.timeout(ANISKIP_FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
try {
|
try {
|
||||||
@@ -311,13 +342,17 @@ function detectEpisodeFromName(baseName: string): number | null {
|
|||||||
const patterns = [
|
const patterns = [
|
||||||
/[Ss]\d+[Ee](\d{1,3})/,
|
/[Ss]\d+[Ee](\d{1,3})/,
|
||||||
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
|
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
|
||||||
|
/(?:^|[\s._-])(\d{1,3})(?:$|[\s._-])/,
|
||||||
/[-\s](\d{1,3})$/,
|
/[-\s](\d{1,3})$/,
|
||||||
];
|
];
|
||||||
for (const pattern of patterns) {
|
const groupStrippedName = baseName.replace(/\[[^\]]+\]/g, ' ').replace(/\([^)]+\)/g, ' ');
|
||||||
const match = baseName.match(pattern);
|
for (const candidate of [baseName, groupStrippedName]) {
|
||||||
if (!match || !match[1]) continue;
|
for (const pattern of patterns) {
|
||||||
const parsed = Number.parseInt(match[1], 10);
|
const match = candidate.match(pattern);
|
||||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
if (!match || !match[1]) continue;
|
||||||
|
const parsed = Number.parseInt(match[1], 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -379,6 +414,7 @@ function cleanupTitle(value: string): string {
|
|||||||
.replace(/\([^)]+\)/g, ' ')
|
.replace(/\([^)]+\)/g, ' ')
|
||||||
.replace(/[Ss]\d+[Ee]\d+/g, ' ')
|
.replace(/[Ss]\d+[Ee]\d+/g, ' ')
|
||||||
.replace(/[Ee][Pp]?[\s._-]*\d+/g, ' ')
|
.replace(/[Ee][Pp]?[\s._-]*\d+/g, ' ')
|
||||||
|
.replace(/(?:^|[\s._-])\d{1,3}\s*$/g, ' ')
|
||||||
.replace(/[_\-.]+/g, ' ')
|
.replace(/[_\-.]+/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -443,7 +479,7 @@ function defaultRunGuessit(mediaPath: string): string | null {
|
|||||||
|
|
||||||
export function inferAniSkipMetadataForFile(
|
export function inferAniSkipMetadataForFile(
|
||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
deps: InferAniSkipDeps = { commandExists, runGuessit: defaultRunGuessit },
|
deps: InferAniSkipDeps = { commandExists: commandExistsOnPath, runGuessit: defaultRunGuessit },
|
||||||
): AniSkipMetadata {
|
): AniSkipMetadata {
|
||||||
if (deps.commandExists('guessit')) {
|
if (deps.commandExists('guessit')) {
|
||||||
const stdout = deps.runGuessit(mediaPath);
|
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(',');
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createAniSkipRuntime, isRemoteMediaPath, AniSkipRuntimeDeps } from './aniskip-runtime';
|
||||||
|
import type { AniSkipMetadata } from './aniskip-metadata';
|
||||||
|
|
||||||
|
function readyMetadata(overrides: Partial<AniSkipMetadata> = {}): AniSkipMetadata {
|
||||||
|
return {
|
||||||
|
title: 'My Show',
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
source: 'fallback',
|
||||||
|
malId: 1234,
|
||||||
|
introStart: 10,
|
||||||
|
introEnd: 95.5,
|
||||||
|
lookupStatus: 'ready',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(options?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
buttonKey?: string;
|
||||||
|
metadata?: AniSkipMetadata | (() => Promise<AniSkipMetadata>);
|
||||||
|
chapterList?: unknown;
|
||||||
|
}) {
|
||||||
|
const state = {
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
buttonKey: options?.buttonKey ?? 'TAB',
|
||||||
|
commands: [] as unknown[][],
|
||||||
|
osd: [] as string[],
|
||||||
|
resolveCalls: [] as string[],
|
||||||
|
connected: true,
|
||||||
|
timePos: 0,
|
||||||
|
chapterList: options?.chapterList ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps: AniSkipRuntimeDeps = {
|
||||||
|
getAniSkipConfig: () => ({
|
||||||
|
aniskipEnabled: state.enabled,
|
||||||
|
aniskipButtonKey: state.buttonKey,
|
||||||
|
}),
|
||||||
|
resolveMetadataForFile: async (mediaPath) => {
|
||||||
|
state.resolveCalls.push(mediaPath);
|
||||||
|
const metadata = options?.metadata;
|
||||||
|
if (typeof metadata === 'function') return metadata();
|
||||||
|
return metadata ?? readyMetadata();
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
state.commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'chapter-list') return state.chapterList;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
isMpvConnected: () => state.connected,
|
||||||
|
getCurrentTimePos: () => state.timePos,
|
||||||
|
showMpvOsd: (text) => {
|
||||||
|
state.osd.push(text);
|
||||||
|
},
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
logDebug: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { runtime: createAniSkipRuntime(deps), state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chapterListCommands(commands: unknown[][]): unknown[][] {
|
||||||
|
return commands.filter(
|
||||||
|
(command) => command[0] === 'set_property' && command[1] === 'chapter-list',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsync(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('isRemoteMediaPath detects URLs but not local paths', () => {
|
||||||
|
assert.equal(isRemoteMediaPath('https://example.com/stream.mkv'), true);
|
||||||
|
assert.equal(isRemoteMediaPath('rtmp://example.com/live'), true);
|
||||||
|
assert.equal(isRemoteMediaPath('/media/anime/show.mkv'), false);
|
||||||
|
assert.equal(isRemoteMediaPath('C:\\media\\show.mkv'), false);
|
||||||
|
assert.equal(isRemoteMediaPath(''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media path change resolves metadata and sets AniSkip chapters', async () => {
|
||||||
|
const { runtime, state } = createHarness({
|
||||||
|
chapterList: [{ time: 0, title: 'Prologue' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/anime/My Show/ep1.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
assert.deepEqual(state.resolveCalls, ['/media/anime/My Show/ep1.mkv']);
|
||||||
|
const chapterCommands = chapterListCommands(state.commands);
|
||||||
|
assert.equal(chapterCommands.length, 1);
|
||||||
|
const chapters = chapterCommands[0]![2] as Array<{ time: number; title: string }>;
|
||||||
|
assert.deepEqual(chapters, [
|
||||||
|
{ time: 0, title: 'Prologue' },
|
||||||
|
{ time: 10, title: 'AniSkip Intro Start' },
|
||||||
|
{ time: 95.5, title: 'AniSkip Intro End' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(runtime.getIntroWindow(), { start: 10, end: 95.5, malId: 1234 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote media paths and disabled config never resolve', async () => {
|
||||||
|
const remote = createHarness();
|
||||||
|
remote.runtime.handleMediaPathChange({ path: 'https://example.com/video.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.deepEqual(remote.state.resolveCalls, []);
|
||||||
|
|
||||||
|
const disabled = createHarness({ enabled: false });
|
||||||
|
disabled.runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.deepEqual(disabled.state.resolveCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skip intro seeks to intro end only inside the intro window', async () => {
|
||||||
|
const { runtime, state } = createHarness();
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
state.timePos = 200;
|
||||||
|
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||||
|
assert.deepEqual(state.osd, ['Skip intro only during intro']);
|
||||||
|
|
||||||
|
state.timePos = 30;
|
||||||
|
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||||
|
assert.deepEqual(state.osd, ['Skip intro only during intro', 'Skipped intro']);
|
||||||
|
const seek = state.commands.find(
|
||||||
|
(command) => command[0] === 'set_property' && command[1] === 'time-pos',
|
||||||
|
);
|
||||||
|
assert.deepEqual(seek, ['set_property', 'time-pos', 95.5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skip intro reports unavailable when no window was found', () => {
|
||||||
|
const { runtime, state } = createHarness();
|
||||||
|
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||||
|
assert.deepEqual(state.osd, ['Intro skip unavailable']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('time-pos prompt shows once near intro start', async () => {
|
||||||
|
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
runtime.handleTimePosChange({ time: 5 });
|
||||||
|
assert.deepEqual(state.osd, []);
|
||||||
|
|
||||||
|
runtime.handleTimePosChange({ time: 10.5 });
|
||||||
|
runtime.handleTimePosChange({ time: 11 });
|
||||||
|
assert.deepEqual(state.osd, ['You can skip by pressing TAB']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connection change binds skip key and legacy fallback for custom keys', () => {
|
||||||
|
const { runtime, state } = createHarness({ buttonKey: 'F6' });
|
||||||
|
runtime.handleConnectionChange({ connected: true });
|
||||||
|
assert.deepEqual(state.commands, [
|
||||||
|
['keybind', 'F6', 'script-message subminer-skip-intro'],
|
||||||
|
['keybind', 'y-k', 'script-message subminer-skip-intro'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default key binds without duplicate legacy fallback', () => {
|
||||||
|
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||||
|
runtime.handleConnectionChange({ connected: true });
|
||||||
|
assert.deepEqual(state.commands, [['keybind', 'TAB', 'script-message subminer-skip-intro']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config change rebinds key and disabling unbinds and clears chapters', async () => {
|
||||||
|
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||||
|
runtime.handleConnectionChange({ connected: true });
|
||||||
|
|
||||||
|
state.buttonKey = 'F6';
|
||||||
|
runtime.applyConfigChange();
|
||||||
|
assert.deepEqual(state.commands.slice(1), [
|
||||||
|
['keybind', 'TAB', ''],
|
||||||
|
['keybind', 'F6', 'script-message subminer-skip-intro'],
|
||||||
|
['keybind', 'y-k', 'script-message subminer-skip-intro'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
state.commands.length = 0;
|
||||||
|
state.enabled = false;
|
||||||
|
state.chapterList = [
|
||||||
|
{ time: 0, title: 'Prologue' },
|
||||||
|
{ time: 10, title: 'AniSkip Intro Start' },
|
||||||
|
{ time: 95.5, title: 'AniSkip Intro End' },
|
||||||
|
];
|
||||||
|
runtime.applyConfigChange();
|
||||||
|
await flushAsync();
|
||||||
|
assert.deepEqual(state.commands, [
|
||||||
|
['keybind', 'F6', ''],
|
||||||
|
['keybind', 'y-k', ''],
|
||||||
|
['set_property', 'chapter-list', [{ time: 0, title: 'Prologue' }]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-media reload re-applies chapters without a new lookup', async () => {
|
||||||
|
const { runtime, state } = createHarness();
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.equal(state.resolveCalls.length, 1);
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.equal(state.resolveCalls.length, 1);
|
||||||
|
assert.equal(chapterListCommands(state.commands).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aniskip refresh forces a fresh lookup for the current media', async () => {
|
||||||
|
const { runtime, state } = createHarness();
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.equal(state.resolveCalls.length, 1);
|
||||||
|
|
||||||
|
runtime.handleClientMessage({ args: ['subminer-aniskip-refresh'] });
|
||||||
|
await flushAsync();
|
||||||
|
assert.equal(state.resolveCalls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media without an intro window is cached and never re-resolved on reload of another file', async () => {
|
||||||
|
const { runtime, state } = createHarness({
|
||||||
|
metadata: readyMetadata({
|
||||||
|
malId: 1234,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'missing_payload',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.equal(runtime.getIntroWindow(), null);
|
||||||
|
assert.equal(chapterListCommands(state.commands).length, 0);
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/other.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.deepEqual(state.resolveCalls, ['/media/show.mkv', '/media/other.mkv']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transient lookup failures are retried on the next media load', async () => {
|
||||||
|
let failures = 0;
|
||||||
|
const { runtime, state } = createHarness({
|
||||||
|
metadata: async () => {
|
||||||
|
failures += 1;
|
||||||
|
return readyMetadata(
|
||||||
|
failures === 1 ? { introStart: null, introEnd: null, lookupStatus: 'lookup_failed' } : {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.equal(runtime.getIntroWindow(), null);
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange({ path: '' });
|
||||||
|
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||||
|
await flushAsync();
|
||||||
|
assert.equal(state.resolveCalls.length, 2);
|
||||||
|
assert.deepEqual(runtime.getIntroWindow(), { start: 10, end: 95.5, malId: 1234 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect clears bindings so reconnect rebinds the skip key', () => {
|
||||||
|
const { runtime, state } = createHarness();
|
||||||
|
runtime.handleConnectionChange({ connected: true });
|
||||||
|
runtime.handleConnectionChange({ connected: false });
|
||||||
|
runtime.handleConnectionChange({ connected: true });
|
||||||
|
assert.deepEqual(state.commands, [
|
||||||
|
['keybind', 'TAB', 'script-message subminer-skip-intro'],
|
||||||
|
['keybind', 'TAB', 'script-message subminer-skip-intro'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import type { AniSkipMetadata } from './aniskip-metadata';
|
||||||
|
|
||||||
|
export const ANISKIP_SKIP_INTRO_MESSAGE = 'subminer-skip-intro';
|
||||||
|
export const ANISKIP_REFRESH_MESSAGE = 'subminer-aniskip-refresh';
|
||||||
|
|
||||||
|
const DEFAULT_ANISKIP_BUTTON_KEY = 'TAB';
|
||||||
|
const LEGACY_ANISKIP_BUTTON_KEY = 'y-k';
|
||||||
|
const ANISKIP_CHAPTER_PREFIX = 'AniSkip ';
|
||||||
|
const SKIP_WINDOW_EPSILON_SECONDS = 0.35;
|
||||||
|
const PROMPT_WINDOW_SECONDS = 3;
|
||||||
|
const PROMPT_OSD_DURATION_MS = 3000;
|
||||||
|
export interface AniSkipRuntimeConfig {
|
||||||
|
aniskipEnabled: boolean;
|
||||||
|
aniskipButtonKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AniSkipRuntimeDeps {
|
||||||
|
getAniSkipConfig: () => AniSkipRuntimeConfig;
|
||||||
|
resolveMetadataForFile: (mediaPath: string) => Promise<AniSkipMetadata>;
|
||||||
|
sendMpvCommand: (command: unknown[]) => void;
|
||||||
|
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||||
|
isMpvConnected: () => boolean;
|
||||||
|
getCurrentTimePos: () => number;
|
||||||
|
showMpvOsd: (text: string, durationMs: number) => void;
|
||||||
|
logInfo: (message: string) => void;
|
||||||
|
logWarn: (message: string, error?: unknown) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AniSkipIntroWindow {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
malId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MpvChapter = { time?: unknown; title?: unknown };
|
||||||
|
|
||||||
|
export function isRemoteMediaPath(mediaPath: string): boolean {
|
||||||
|
return /^[a-zA-Z][\w+.-]*:\/\//.test(mediaPath.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
|
||||||
|
let requestGeneration = 0;
|
||||||
|
let currentMediaPath = '';
|
||||||
|
let introWindow: AniSkipIntroWindow | null = null;
|
||||||
|
let promptShown = false;
|
||||||
|
let boundButtonKey: string | null = null;
|
||||||
|
let legacyFallbackBound = false;
|
||||||
|
const introWindowCache = new Map<string, AniSkipIntroWindow | null>();
|
||||||
|
|
||||||
|
function resolveButtonKey(): string {
|
||||||
|
const key = deps.getAniSkipConfig().aniskipButtonKey.trim();
|
||||||
|
return key || DEFAULT_ANISKIP_BUTTON_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSkipKeys(): void {
|
||||||
|
if (!deps.isMpvConnected()) return;
|
||||||
|
const enabled = deps.getAniSkipConfig().aniskipEnabled;
|
||||||
|
const key = resolveButtonKey();
|
||||||
|
const wantLegacyFallback =
|
||||||
|
enabled && key !== LEGACY_ANISKIP_BUTTON_KEY && key !== DEFAULT_ANISKIP_BUTTON_KEY;
|
||||||
|
|
||||||
|
if (boundButtonKey && (!enabled || boundButtonKey !== key)) {
|
||||||
|
deps.sendMpvCommand(['keybind', boundButtonKey, '']);
|
||||||
|
boundButtonKey = null;
|
||||||
|
}
|
||||||
|
if (legacyFallbackBound && !wantLegacyFallback) {
|
||||||
|
deps.sendMpvCommand(['keybind', LEGACY_ANISKIP_BUTTON_KEY, '']);
|
||||||
|
legacyFallbackBound = false;
|
||||||
|
}
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
if (boundButtonKey !== key) {
|
||||||
|
deps.sendMpvCommand(['keybind', key, `script-message ${ANISKIP_SKIP_INTRO_MESSAGE}`]);
|
||||||
|
boundButtonKey = key;
|
||||||
|
}
|
||||||
|
if (wantLegacyFallback && !legacyFallbackBound) {
|
||||||
|
deps.sendMpvCommand([
|
||||||
|
'keybind',
|
||||||
|
LEGACY_ANISKIP_BUTTON_KEY,
|
||||||
|
`script-message ${ANISKIP_SKIP_INTRO_MESSAGE}`,
|
||||||
|
]);
|
||||||
|
legacyFallbackBound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setIntroChapters(introStart: number, introEnd: number): Promise<void> {
|
||||||
|
let existing: MpvChapter[] = [];
|
||||||
|
try {
|
||||||
|
const chapterList = await deps.requestMpvProperty('chapter-list');
|
||||||
|
if (Array.isArray(chapterList)) {
|
||||||
|
existing = chapterList as MpvChapter[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// chapter-list may be unavailable mid-load; fall back to AniSkip chapters only
|
||||||
|
}
|
||||||
|
const chapters = existing.filter(
|
||||||
|
(chapter) =>
|
||||||
|
typeof chapter?.title !== 'string' || !chapter.title.startsWith(ANISKIP_CHAPTER_PREFIX),
|
||||||
|
);
|
||||||
|
chapters.push({ time: introStart, title: 'AniSkip Intro Start' });
|
||||||
|
chapters.push({ time: introEnd, title: 'AniSkip Intro End' });
|
||||||
|
chapters.sort((a, b) => {
|
||||||
|
const aTime = typeof a.time === 'number' ? a.time : 0;
|
||||||
|
const bTime = typeof b.time === 'number' ? b.time : 0;
|
||||||
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
deps.sendMpvCommand(['set_property', 'chapter-list', chapters]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeIntroChapters(): Promise<void> {
|
||||||
|
if (!deps.isMpvConnected()) return;
|
||||||
|
let existing: MpvChapter[] = [];
|
||||||
|
try {
|
||||||
|
const chapterList = await deps.requestMpvProperty('chapter-list');
|
||||||
|
if (Array.isArray(chapterList)) {
|
||||||
|
existing = chapterList as MpvChapter[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filtered = existing.filter(
|
||||||
|
(chapter) =>
|
||||||
|
typeof chapter?.title !== 'string' || !chapter.title.startsWith(ANISKIP_CHAPTER_PREFIX),
|
||||||
|
);
|
||||||
|
if (filtered.length !== existing.length) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'chapter-list', filtered]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearState(): void {
|
||||||
|
requestGeneration += 1;
|
||||||
|
introWindow = null;
|
||||||
|
promptShown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyIntroWindow(window: AniSkipIntroWindow): Promise<void> {
|
||||||
|
introWindow = window;
|
||||||
|
promptShown = false;
|
||||||
|
await setIntroChapters(window.start, window.end);
|
||||||
|
deps.logInfo(
|
||||||
|
`AniSkip intro window ${window.start.toFixed(3)} -> ${window.end.toFixed(3)} (MAL ${
|
||||||
|
window.malId ?? '-'
|
||||||
|
})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveForMedia(mediaPath: string, options?: { force?: boolean }): Promise<void> {
|
||||||
|
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||||
|
if (!mediaPath || isRemoteMediaPath(mediaPath)) {
|
||||||
|
deps.logDebug('AniSkip lookup skipped: no local media path');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.force) {
|
||||||
|
introWindowCache.delete(mediaPath);
|
||||||
|
}
|
||||||
|
const generation = requestGeneration;
|
||||||
|
const cached = introWindowCache.get(mediaPath);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
if (cached) {
|
||||||
|
await applyIntroWindow(cached);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata: AniSkipMetadata;
|
||||||
|
try {
|
||||||
|
metadata = await deps.resolveMetadataForFile(mediaPath);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('AniSkip metadata lookup failed', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generation !== requestGeneration || mediaPath !== currentMediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
metadata.lookupStatus !== 'ready' ||
|
||||||
|
metadata.introStart === null ||
|
||||||
|
metadata.introEnd === null ||
|
||||||
|
metadata.introEnd <= metadata.introStart
|
||||||
|
) {
|
||||||
|
// Only definitive "no skip window exists" results are cached; transient
|
||||||
|
// lookup failures stay retryable on the next load or refresh.
|
||||||
|
if (metadata.lookupStatus !== 'lookup_failed') {
|
||||||
|
introWindowCache.set(mediaPath, null);
|
||||||
|
}
|
||||||
|
deps.logInfo(
|
||||||
|
`AniSkip: no intro window for "${metadata.title}" (status=${metadata.lookupStatus ?? 'unknown'})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const window: AniSkipIntroWindow = {
|
||||||
|
start: metadata.introStart,
|
||||||
|
end: metadata.introEnd,
|
||||||
|
malId: metadata.malId,
|
||||||
|
};
|
||||||
|
introWindowCache.set(mediaPath, window);
|
||||||
|
await applyIntroWindow(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIntroNow(): void {
|
||||||
|
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||||
|
if (!introWindow) {
|
||||||
|
deps.showMpvOsd('Intro skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = deps.getCurrentTimePos();
|
||||||
|
if (!Number.isFinite(now)) {
|
||||||
|
deps.showMpvOsd('Skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
now < introWindow.start - SKIP_WINDOW_EPSILON_SECONDS ||
|
||||||
|
now > introWindow.end + SKIP_WINDOW_EPSILON_SECONDS
|
||||||
|
) {
|
||||||
|
deps.showMpvOsd('Skip intro only during intro', PROMPT_OSD_DURATION_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.sendMpvCommand(['set_property', 'time-pos', introWindow.end]);
|
||||||
|
deps.showMpvOsd('Skipped intro', PROMPT_OSD_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimePosChange({ time }: { time: number }): void {
|
||||||
|
if (!introWindow || promptShown) return;
|
||||||
|
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||||
|
const promptWindowEnd = Math.min(introWindow.start + PROMPT_WINDOW_SECONDS, introWindow.end);
|
||||||
|
if (time >= introWindow.start && time < promptWindowEnd) {
|
||||||
|
promptShown = true;
|
||||||
|
deps.showMpvOsd(`You can skip by pressing ${resolveButtonKey()}`, PROMPT_OSD_DURATION_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMediaPathChange({ path }: { path: string }): void {
|
||||||
|
const nextPath = typeof path === 'string' ? path : '';
|
||||||
|
if (nextPath === currentMediaPath && introWindow) {
|
||||||
|
// Same-media reload: mpv rebuilt the chapter list, so re-apply markers.
|
||||||
|
void setIntroChapters(introWindow.start, introWindow.end).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentMediaPath = nextPath;
|
||||||
|
clearState();
|
||||||
|
if (!nextPath) return;
|
||||||
|
void resolveForMedia(nextPath).catch((error) => {
|
||||||
|
deps.logWarn('AniSkip media resolution failed', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnectionChange({ connected }: { connected: boolean }): void {
|
||||||
|
if (!connected) {
|
||||||
|
boundButtonKey = null;
|
||||||
|
legacyFallbackBound = false;
|
||||||
|
clearState();
|
||||||
|
currentMediaPath = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bindSkipKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClientMessage({ args }: { args: string[] }): void {
|
||||||
|
const messageName = args[0];
|
||||||
|
if (messageName === ANISKIP_SKIP_INTRO_MESSAGE) {
|
||||||
|
skipIntroNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messageName === ANISKIP_REFRESH_MESSAGE) {
|
||||||
|
const mediaPath = currentMediaPath;
|
||||||
|
if (!mediaPath) return;
|
||||||
|
clearState();
|
||||||
|
void removeIntroChapters().catch(() => {});
|
||||||
|
void resolveForMedia(mediaPath, { force: true }).catch((error) => {
|
||||||
|
deps.logWarn('AniSkip refresh failed', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConfigChange(): void {
|
||||||
|
bindSkipKeys();
|
||||||
|
const enabled = deps.getAniSkipConfig().aniskipEnabled;
|
||||||
|
if (!enabled) {
|
||||||
|
clearState();
|
||||||
|
void removeIntroChapters().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!introWindow && currentMediaPath) {
|
||||||
|
void resolveForMedia(currentMediaPath).catch((error) => {
|
||||||
|
deps.logWarn('AniSkip media resolution failed', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleConnectionChange,
|
||||||
|
handleMediaPathChange,
|
||||||
|
handleTimePosChange,
|
||||||
|
handleClientMessage,
|
||||||
|
applyConfigChange,
|
||||||
|
skipIntroNow,
|
||||||
|
getIntroWindow: () => introWindow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AniSkipRuntime = ReturnType<typeof createAniSkipRuntime>;
|
||||||
@@ -22,6 +22,7 @@ type ConfigHotReloadAppliedDeps = {
|
|||||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||||
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
||||||
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
||||||
|
applyAniSkipConfig?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConfigHotReloadMessageDeps = {
|
type ConfigHotReloadMessageDeps = {
|
||||||
@@ -170,6 +171,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
|||||||
deps.setLogFileToggles?.(config.logging.files);
|
deps.setLogFileToggles?.(config.logging.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasAnyHotReloadField(diff, ['mpv.aniskipEnabled', 'mpv.aniskipButtonKey'])) {
|
||||||
|
deps.applyAniSkipConfig?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (diff.hotReloadFields.length > 0) {
|
if (diff.hotReloadFields.length > 0) {
|
||||||
deps.broadcastToOverlayWindows('config:hot-reload', payload);
|
deps.broadcastToOverlayWindows('config:hot-reload', payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
|||||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||||
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
||||||
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
||||||
|
applyAniSkipConfig?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||||
@@ -99,6 +100,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
|||||||
deps.setLogRotation?.(rotation),
|
deps.setLogRotation?.(rotation),
|
||||||
setLogFileToggles: (files: ResolvedConfig['logging']['files']) =>
|
setLogFileToggles: (files: ResolvedConfig['logging']['files']) =>
|
||||||
deps.setLogFileToggles?.(files),
|
deps.setLogFileToggles?.(files),
|
||||||
|
applyAniSkipConfig: () => deps.applyAniSkipConfig?.(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
|||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'F8',
|
|
||||||
}),
|
}),
|
||||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||||
defaultMpvArgs: ['--sid=auto'],
|
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_visible_overlay=no/);
|
||||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_enabled=/);
|
||||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||||
|
|||||||
@@ -211,8 +211,6 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
|
|||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
texthookerEnabled: 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_visible_overlay=no/);
|
||||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=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', () => {
|
test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly overridden', () => {
|
||||||
@@ -243,8 +239,6 @@ test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly over
|
|||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
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,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: 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,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -447,8 +437,6 @@ test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async
|
|||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
|
||||||
aniskipButtonKey: 'TAB',
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ export interface SubminerPluginRuntimeScriptOptConfig {
|
|||||||
autoStartVisibleOverlay: boolean;
|
autoStartVisibleOverlay: boolean;
|
||||||
autoStartPauseUntilReady: boolean;
|
autoStartPauseUntilReady: boolean;
|
||||||
texthookerEnabled: boolean;
|
texthookerEnabled: boolean;
|
||||||
aniskipEnabled: boolean;
|
|
||||||
aniskipButtonKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function boolScriptOpt(value: boolean): 'yes' | 'no' {
|
function boolScriptOpt(value: boolean): 'yes' | 'no' {
|
||||||
@@ -34,7 +32,6 @@ export function buildSubminerPluginRuntimeScriptOptParts(
|
|||||||
const binaryPath = sanitizeScriptOptValue(runtimeConfig.binaryPath?.trim() || fallbackAppPath);
|
const binaryPath = sanitizeScriptOptValue(runtimeConfig.binaryPath?.trim() || fallbackAppPath);
|
||||||
const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath);
|
const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath);
|
||||||
const backend = sanitizeScriptOptValue(runtimeConfig.backend);
|
const backend = sanitizeScriptOptValue(runtimeConfig.backend);
|
||||||
const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey);
|
|
||||||
return [
|
return [
|
||||||
`subminer-binary_path=${binaryPath}`,
|
`subminer-binary_path=${binaryPath}`,
|
||||||
`subminer-socket_path=${socketPath}`,
|
`subminer-socket_path=${socketPath}`,
|
||||||
@@ -45,7 +42,5 @@ export function buildSubminerPluginRuntimeScriptOptParts(
|
|||||||
runtimeConfig.autoStartPauseUntilReady,
|
runtimeConfig.autoStartPauseUntilReady,
|
||||||
)}`,
|
)}`,
|
||||||
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
|
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
|
||||||
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
|
|
||||||
`subminer-aniskip_button_key=${aniskipButtonKey}`,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user