From f6e7dd496a29e202b4d33923e0e6ce0dba93864c Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 22 Feb 2026 02:14:37 -0800 Subject: [PATCH] feat(plugin): add AniSkip intro skip flow with launcher metadata hints --- ...108 - Add-AniSkip-intro-skip-OSD-button.md | 68 +++ docs/mpv-plugin.md | 44 ++ docs/subagents/INDEX.md | 2 + ...niskip-intro-skip-20260222T080257Z-51fx.md | 48 ++ docs/subagents/collaboration.md | 15 + launcher/aniskip-metadata.test.ts | 75 +++ launcher/aniskip-metadata.ts | 196 ++++++ launcher/mpv.ts | 13 +- plugin/subminer.conf | 28 + plugin/subminer.lua | 568 ++++++++++++++++++ 10 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 backlog/tasks/task-108 - Add-AniSkip-intro-skip-OSD-button.md create mode 100644 docs/subagents/agents/codex-aniskip-intro-skip-20260222T080257Z-51fx.md create mode 100644 launcher/aniskip-metadata.test.ts create mode 100644 launcher/aniskip-metadata.ts diff --git a/backlog/tasks/task-108 - Add-AniSkip-intro-skip-OSD-button.md b/backlog/tasks/task-108 - Add-AniSkip-intro-skip-OSD-button.md new file mode 100644 index 0000000..085facb --- /dev/null +++ b/backlog/tasks/task-108 - Add-AniSkip-intro-skip-OSD-button.md @@ -0,0 +1,68 @@ +--- +id: TASK-108 +title: Add AniSkip intro skip markers and OSD skip button in mpv plugin +status: In Progress +assignee: + - codex +created_date: '2026-02-22 08:05' +updated_date: '2026-02-22 09:26' +labels: + - feature + - mpv + - intro-skip +dependencies: [] +priority: high +ordinal: 98100 +--- + +## Description + + +Wire `plugin/subminer.lua` to call AniSkip API, parse intro skip window, add skip markers/chapters to mpv, and show an OSD skip button while playback is inside the intro range. + + +## Action Steps + + +1. Add configurable AniSkip options + state in plugin. +2. Resolve anime id + episode from mpv metadata/filename. +3. Fetch AniSkip skip-times API and parse OP interval. +4. Create/update chapters for OP marker. +5. Render clickable OSD skip button while inside OP range. +6. Add manual script message to retry fetch. +7. Update plugin docs/config comments. + + +## Acceptance Criteria + +- [ ] #1 Plugin calls AniSkip API and handles missing data gracefully. +- [ ] #2 Intro marker/chapter is visible in mpv when OP range exists. +- [ ] #3 OSD skip button appears only while inside OP range. +- [ ] #4 Clicking/activating button seeks to OP end. +- [ ] #5 Docs/config include new options + script message. + + +## Implementation Notes + + +Linked to user request on 2026-02-22 for porting intro skip via AniSkip. +- Follow-up implemented per user request: + - launcher now runs `guessit` for file targets and passes `subminer-aniskip_title`, `subminer-aniskip_season`, `subminer-aniskip_episode` via mpv `--script-opts` + - fallback metadata path passes filename-derived title when `guessit` is unavailable/empty + - intro hint now displays for first 3 seconds from intro start (`You can skip by pressing y-k`) +- Runtime bugfix follow-up: + - always binds `y-k` fallback key for intro skip, even when custom `aniskip_button_key` configured + - intro skip handler now shows explicit OSD reason if skip is unavailable or outside intro window +- Validation: + - `bun test launcher/aniskip-metadata.test.ts` + - `bun test launcher/mpv.test.ts` + - `luac -p plugin/subminer.lua` + - `bun run tsc --noEmit` + + +## Definition of Done + +- [ ] #1 Focused plugin smoke validation in mpv. +- [ ] #2 Lua parse/load check passes in local environment. +- [ ] #3 Task notes capture fallback behavior. + diff --git a/docs/mpv-plugin.md b/docs/mpv-plugin.md index ff2abbd..bb37208 100644 --- a/docs/mpv-plugin.md +++ b/docs/mpv-plugin.md @@ -38,6 +38,7 @@ All keybindings use a `y` chord prefix — press `y`, then the second key: | `y-o` | Open settings window | | `y-r` | Restart overlay | | `y-c` | Check status | +| `y-k` | Skip intro (AniSkip) | ## Menu @@ -91,6 +92,29 @@ osd_messages=yes # Logging level: debug, info, warn, error. log_level=info + +# Enable AniSkip intro detection/markers. +aniskip_enabled=yes + +# Optional title override (launcher fills from guessit when available). +aniskip_title= + +# Optional season override (launcher fills from guessit when available). +aniskip_season= + +# Optional MAL ID override. Leave blank to resolve from media title. +aniskip_mal_id= + +# Optional episode override. Leave blank to detect from filename/title. +aniskip_episode= + +# Show OSD skip button while inside intro range. +aniskip_show_button=yes + +# OSD label + keybinding for intro skip action. +aniskip_button_text=You can skip by pressing %s +aniskip_button_key=y-k +aniskip_button_duration=3 ``` ### Option Reference @@ -107,6 +131,15 @@ log_level=info | `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start | | `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages | | `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | +| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection | +| `aniskip_title` | `""` | string | Override title used for lookup | +| `aniskip_season` | `""` | numeric season | Optional season hint | +| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id | +| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed | +| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt | +| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) | +| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) | +| `aniskip_button_duration` | `3` | float seconds | OSD hint duration | ## Binary Auto-Detection @@ -155,6 +188,8 @@ script-message subminer-menu script-message subminer-options script-message subminer-restart script-message subminer-status +script-message subminer-aniskip-refresh +script-message subminer-skip-intro ``` The `subminer-start` message accepts overrides: @@ -166,6 +201,15 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no `log-level` here controls only logging verbosity passed to SubMiner. `--debug` is a separate app/dev-mode flag in the main CLI and should not be used here for logging. +## AniSkip Intro Skip + +- On file load, plugin resolves title + episode, resolves MAL id, then calls AniSkip API. +- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title. +- 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 y-k` by default). +- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup. + ## Lifecycle - **File loaded**: If `auto_start=yes`, the plugin starts the overlay and applies visibility preferences after a short delay. diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index 1ac48a5..54c76d0 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -4,6 +4,7 @@ Read first. Keep concise. | agent_id | alias | mission | status | file | last_update_utc | | ------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------- | ---------------------- | +| `codex-aniskip-intro-skip-20260222T080257Z-51fx` | `codex-aniskip-intro-skip` | `Port intro skip to AniSkip API in mpv plugin with OSD skip button` | `handoff` | `docs/subagents/agents/codex-aniskip-intro-skip-20260222T080257Z-51fx.md` | `2026-02-22T10:04:20Z` | | `codex-generate-minecard-image-20260220T112900Z-vsxr` | `codex-generate-minecard-image` | `Generate media fallbacks (GIF) from assets/minecard.webm and wire README/docs fallback markup` | `done` | `docs/subagents/agents/codex-generate-minecard-image-20260220T112900Z-vsxr.md` | `2026-02-20T11:35:30Z` | | `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | | `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T11:42:39Z` | @@ -69,3 +70,4 @@ Read first. Keep concise. | `codex-review-cleanup-20260222T065718Z-9p4m` | `codex-review-cleanup` | `Review post-refactor codebase quality and create cleanup tickets with concrete scope and completion criteria` | `done` | `docs/subagents/agents/codex-review-cleanup-20260222T065718Z-9p4m.md` | `2026-02-22T07:04:48Z` | | `codex-jellyfin-ts-fix-20260222T071530Z-5e50` | `codex-jellyfin-ts-fix` | `Fix Jellyfin token/session type drift causing TS compile failures in config+main.` | `done` | `docs/subagents/agents/codex-jellyfin-ts-fix-20260222T071530Z-5e50.md` | `2026-02-22T07:23:47Z` | | `codex-overlay-toggle-regression-20260222T073450Z-q7m4` | `codex-overlay-toggle-regression` | `Fix post-rebase overlay toggle regression causing transparent non-interactable windows and broken keybinds (TASK-107).` | `testing` | `docs/subagents/agents/codex-overlay-toggle-regression-20260222T073450Z-q7m4.md` | `2026-02-22T07:45:58Z` | +| `codex-docs-review-20260222T094009Z-g8p2` | `codex-docs-review` | `Review README/docs for drift vs current code/scripts; patch stale or missing documentation.` | `done` | `docs/subagents/agents/codex-docs-review-20260222T094009Z-g8p2.md` | `2026-02-22T09:43:52Z` | diff --git a/docs/subagents/agents/codex-aniskip-intro-skip-20260222T080257Z-51fx.md b/docs/subagents/agents/codex-aniskip-intro-skip-20260222T080257Z-51fx.md new file mode 100644 index 0000000..740549c --- /dev/null +++ b/docs/subagents/agents/codex-aniskip-intro-skip-20260222T080257Z-51fx.md @@ -0,0 +1,48 @@ +# Agent: `codex-aniskip-intro-skip-20260222T080257Z-51fx` + +- alias: `codex-aniskip-intro-skip` +- mission: `Port intro skip to AniSkip API in mpv plugin with OSD skip button` +- status: `handoff` +- branch: `main` +- started_at: `2026-02-22T08:02:57Z` +- heartbeat_minutes: `5` + +## Current Work (newest first) +- [2026-02-22T08:03:20Z] intent: inspect plugin flow + ani-skip script behavior; design direct API integration. +- [2026-02-22T08:13:40Z] progress: added launcher guessit metadata extraction + script-opts passthrough (`aniskip_title`, `aniskip_season`, `aniskip_episode`) for file playback. +- [2026-02-22T08:13:40Z] progress: updated plugin intro hint behavior to show `You can skip by pressing y-k` for first 3 seconds from intro start. +- [2026-02-22T08:13:40Z] test: `bun test launcher/aniskip-metadata.test.ts`, `bun test launcher/mpv.test.ts`, `luac -p plugin/subminer.lua`, `bun run tsc --noEmit`. + +## Files Touched +- `plugin/subminer.lua` +- `plugin/subminer.conf` +- `docs/mpv-plugin.md` +- `launcher/aniskip-metadata.ts` +- `launcher/aniskip-metadata.test.ts` +- `launcher/mpv.ts` + +## Assumptions +- mpv build supports `subprocess` curl fallback for HTTP. +- intro skip should be OP-only by default. + +## Open Questions / Blockers +- none + +## Next Step +- manual mpv runtime validation still needed (network + real media metadata). +- [2026-02-22T09:25:57Z] progress: fixed intro skip keypath reliability by always binding fallback `y-k` alias; added explicit OSD feedback when skip not available/outside intro window. +- [2026-02-22T09:25:57Z] test: `luac -p plugin/subminer.lua`; `bun run tsc --noEmit`. +- [2026-02-22T09:30:27Z] progress: added explicit AniSkip query logging in plugin (`title/season/episode` context, MAL lookup query, resolved MAL id, AniSkip URL) for season mismatch diagnosis. +- [2026-02-22T09:30:27Z] test: `luac -p plugin/subminer.lua`. +- [2026-02-22T09:33:22Z] progress: improved MAL resolution heuristic for AniSkip (title overlap + season-aware ranking) to avoid sequel mis-selection; logs chosen MAL candidate title/id/score. +- [2026-02-22T09:33:22Z] test: `luac -p plugin/subminer.lua`. +- [2026-02-22T09:36:05Z] progress: hardened MAL candidate scoring (token overlap + stopword filtering + coverage + low-confidence rejection threshold) to prevent unrelated anime selection; syntax check passed. +- [2026-02-22T09:42:00Z] progress: added MAL lookup fallback attempts across multiple title sources (`aniskip_title`, `media-title`, filename, path) with per-attempt logs; keeps season/episode fixed. +- [2026-02-22T09:57:40Z] progress: tightened title overlap scoring for MAL candidate selection (strong penalty for partial multi-token matches) to prevent wrong-ID picks like "Shadow Skill" from "The Eminence in Shadow". +- [2026-02-22T09:57:40Z] test: `luac -p plugin/subminer.lua`. +- [2026-02-22T10:04:20Z] progress: launcher AniSkip metadata now prioritizes guessit `series` over episode `title`; fallback title extraction now prefers anime directory name before season dir (`...//Season-1/`). +- [2026-02-22T10:04:20Z] test: `bun test launcher/aniskip-metadata.test.ts`, `bun test launcher/mpv.test.ts`, `bun run tsc --noEmit`, `luac -p plugin/subminer.lua`. +- [2026-02-22T10:10:30Z] progress: plugin now extracts show title from media path (`...//Season-*`) and prioritizes it over `aniskip_title`, preventing episode-title queries when launcher metadata is stale/wrong. +- [2026-02-22T10:10:30Z] test: `luac -p plugin/subminer.lua`. +- [2026-02-22T10:14:40Z] progress: per user request removed MAL scoring/rejection; resolver now selects first MAL search result with an id and logs selected first result. +- [2026-02-22T10:14:40Z] test: `luac -p plugin/subminer.lua`. diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index 7e6a817..ae58e9e 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -101,3 +101,18 @@ Shared notes. Append-only. - [2026-02-22T07:34:50Z] [codex-overlay-toggle-regression-20260222T073450Z-q7m4|codex-overlay-toggle-regression] starting TASK-107 bugfix for post-rebase overlay regression: toggling visible/invisible opens transparent non-interactable window; keybinds + subtitle rendering fail in both modes. - [2026-02-22T07:44:40Z] [codex-overlay-toggle-regression-20260222T073450Z-q7m4|codex-overlay-toggle-regression] identified renderer layer-resolution bug risk under shared renderer process (`process.argv` preload arg drift); fixed `resolvePlatformInfo` to prioritize per-window `?layer=` query, added regression test, verified `bun test src/renderer/error-recovery.test.ts` + `bun run build`. - [2026-02-22T07:45:58Z] [codex-overlay-toggle-regression-20260222T073450Z-q7m4|codex-overlay-toggle-regression] added explicit overlay BrowserWindow sandbox guard (`webPreferences.sandbox=false`) to avoid preload API break on newer Electron defaults; added regression test `src/core/services/overlay-window-config.test.ts`; verified focused tests + build green. + +## 2026-02-22 +- [2026-02-22T08:04:10Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] starting feature: port intro skip to AniSkip API in `plugin/subminer.lua` with chapter markers + in-range OSD skip button; scoped to mpv plugin/docs only. +- [2026-02-22T08:05:38Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] implemented AniSkip OP lookup + chapter markers + in-range OSD skip prompt/key in `plugin/subminer.lua`; updated plugin conf/docs; syntax check `luac -p plugin/subminer.lua` passed. +- [2026-02-22T08:13:40Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] follow-up applied: launcher now runs `guessit` for file playback and passes AniSkip title/season/episode script-opts (fallback title from filename), and intro hint now displays for first 3s from intro start (`You can skip by pressing y-k`). +- [2026-02-22T09:25:57Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] bugfix follow-up: `y-k` now always bound as fallback intro-skip key regardless custom key config; skip handler now emits OSD reason when unavailable/outside intro window to aid diagnosis. +- [2026-02-22T09:30:27Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] diagnostics follow-up: plugin now logs AniSkip query context + MAL lookup query + resolved MAL id + AniSkip URL to help debug wrong-season resolution. +- [2026-02-22T09:33:22Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] season-mismatch mitigation: MAL lookup now ranks prefix-search candidates by title overlap + season signal, logs selected candidate name/id/score before AniSkip query. +- [2026-02-22T09:36:05Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] tightened MAL resolver scoring and added low-confidence rejection (<35) so unrelated matches are skipped instead of used. +- [2026-02-22T09:40:09Z] [codex-docs-review-20260222T094009Z-g8p2|codex-docs-review] starting docs review/update pass for README + docs pages; scope docs-only consistency fixes against current scripts/config/features. +- [2026-02-22T09:43:52Z] [codex-docs-review-20260222T094009Z-g8p2|codex-docs-review] completed docs review/update pass: removed stale guardrail docs, fixed DevTools shortcut docs, added guessit guidance for AniSkip metadata quality, verified `bun run docs:build` passes. +- [2026-02-22T09:42:00Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] added AniSkip MAL fallback title attempts to recover from guessit title mismatches while preserving season/episode hints. +- [2026-02-22T10:04:20Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] launcher metadata fix: prefer guessit `series` for AniSkip title and fallback to show-directory extraction (`...//Season-*`) instead of episode filename title. +- [2026-02-22T10:10:30Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] plugin hardening: path-derived show title now prioritized over script-opt title for AniSkip lookups, reducing dependence on launcher metadata correctness. +- [2026-02-22T10:14:40Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] simplified MAL resolution policy to first-result selection (no score/reject) per user preference. diff --git a/launcher/aniskip-metadata.test.ts b/launcher/aniskip-metadata.test.ts new file mode 100644 index 0000000..0404947 --- /dev/null +++ b/launcher/aniskip-metadata.test.ts @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + inferAniSkipMetadataForFile, + buildSubminerScriptOpts, + parseAniSkipGuessitJson, +} from './aniskip-metadata'; + +test('parseAniSkipGuessitJson extracts title season and episode', () => { + const parsed = parseAniSkipGuessitJson( + JSON.stringify({ title: 'My Show', season: 2, episode: 7 }), + '/tmp/My.Show.S02E07.mkv', + ); + assert.deepEqual(parsed, { + title: 'My Show', + season: 2, + episode: 7, + source: 'guessit', + }); +}); + +test('parseAniSkipGuessitJson prefers series over episode title', () => { + const parsed = parseAniSkipGuessitJson( + JSON.stringify({ + title: 'What Is This, a Picnic', + series: 'Solo Leveling', + season: 1, + episode: 10, + }), + '/tmp/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv', + ); + assert.deepEqual(parsed, { + title: 'Solo Leveling', + season: 1, + episode: 10, + source: 'guessit', + }); +}); + +test('inferAniSkipMetadataForFile falls back to filename title when guessit unavailable', () => { + const parsed = inferAniSkipMetadataForFile('/tmp/Another_Show_-_03.mkv', { + commandExists: () => false, + runGuessit: () => null, + }); + assert.equal(parsed.title.length > 0, true); + assert.equal(parsed.source, 'fallback'); +}); + +test('inferAniSkipMetadataForFile falls back to anime directory title when filename is episode-only', () => { + const parsed = inferAniSkipMetadataForFile( + '/truenas/jellyfin/anime/Solo Leveling/Season-1/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv', + { + commandExists: () => false, + runGuessit: () => null, + }, + ); + assert.equal(parsed.title, 'Solo Leveling'); + assert.equal(parsed.season, 1); + assert.equal(parsed.episode, 10); + assert.equal(parsed.source, 'fallback'); +}); + +test('buildSubminerScriptOpts includes aniskip metadata fields', () => { + const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', { + title: 'Frieren: Beyond Journey\'s End', + season: 1, + episode: 5, + source: 'guessit', + }); + assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); + assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); + 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/); +}); diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts new file mode 100644 index 0000000..ce1a8cc --- /dev/null +++ b/launcher/aniskip-metadata.ts @@ -0,0 +1,196 @@ +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { commandExists } from './util.js'; + +export interface AniSkipMetadata { + title: string; + season: number | null; + episode: number | null; + source: 'guessit' | 'fallback'; +} + +interface InferAniSkipDeps { + commandExists: (name: string) => boolean; + runGuessit: (mediaPath: string) => string | null; +} + +function toPositiveInt(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return null; +} + +function detectEpisodeFromName(baseName: string): number | null { + const patterns = [/[Ss]\d+[Ee](\d{1,3})/, /(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/, /[-\s](\d{1,3})$/]; + for (const pattern of patterns) { + const match = baseName.match(pattern); + if (!match || !match[1]) continue; + const parsed = Number.parseInt(match[1], 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return null; +} + +function detectSeasonFromNameOrDir(mediaPath: string): number | null { + const baseName = path.basename(mediaPath, path.extname(mediaPath)); + const seasonMatch = baseName.match(/[Ss](\d{1,2})[Ee]\d{1,3}/); + if (seasonMatch && seasonMatch[1]) { + const parsed = Number.parseInt(seasonMatch[1], 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + const parent = path.basename(path.dirname(mediaPath)); + const parentMatch = parent.match(/(?:Season|S)[\s._-]*(\d{1,2})/i); + if (parentMatch && parentMatch[1]) { + const parsed = Number.parseInt(parentMatch[1], 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return null; +} + +function isSeasonDirectoryName(value: string): boolean { + return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim()); +} + +function inferTitleFromPath(mediaPath: string): string { + const directory = path.dirname(mediaPath); + const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0); + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index] || ''; + if (!isSeasonDirectoryName(segment)) continue; + const showSegment = segments[index - 1]; + if (typeof showSegment === 'string' && showSegment.length > 0) { + const cleaned = cleanupTitle(showSegment); + if (cleaned) return cleaned; + } + } + + const parent = path.basename(directory); + if (!isSeasonDirectoryName(parent)) { + const cleanedParent = cleanupTitle(parent); + if (cleanedParent) return cleanedParent; + } + + const grandParent = path.basename(path.dirname(directory)); + const cleanedGrandParent = cleanupTitle(grandParent); + return cleanedGrandParent; +} + +function cleanupTitle(value: string): string { + return value + .replace(/\.[^/.]+$/, '') + .replace(/\[[^\]]+\]/g, ' ') + .replace(/\([^)]+\)/g, ' ') + .replace(/[Ss]\d+[Ee]\d+/g, ' ') + .replace(/[Ee][Pp]?[\s._-]*\d+/g, ' ') + .replace(/[_\-.]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniSkipMetadata | null { + const payload = stdout.trim(); + if (!payload) return null; + + try { + const parsed = JSON.parse(payload) as { + title?: unknown; + title_original?: unknown; + series?: unknown; + season?: unknown; + episode?: unknown; + episode_list?: unknown; + }; + + const rawTitle = + (typeof parsed.series === 'string' && parsed.series) || + (typeof parsed.title === 'string' && parsed.title) || + (typeof parsed.title_original === 'string' && parsed.title_original) || + ''; + + const title = cleanupTitle(rawTitle) || inferTitleFromPath(mediaPath); + if (!title) return null; + + const season = toPositiveInt(parsed.season); + const episodeFromDirect = toPositiveInt(parsed.episode); + const episodeFromList = + Array.isArray(parsed.episode_list) && parsed.episode_list.length > 0 + ? toPositiveInt(parsed.episode_list[0]) + : null; + + return { + title, + season, + episode: episodeFromDirect ?? episodeFromList, + source: 'guessit', + }; + } catch { + return null; + } +} + +function defaultRunGuessit(mediaPath: string): string | null { + const fileName = path.basename(mediaPath); + const result = spawnSync('guessit', ['--json', fileName], { + cwd: path.dirname(mediaPath), + encoding: 'utf8', + maxBuffer: 2_000_000, + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + return result.stdout || null; +} + +export function inferAniSkipMetadataForFile( + mediaPath: string, + deps: InferAniSkipDeps = { commandExists, runGuessit: defaultRunGuessit }, +): AniSkipMetadata { + if (deps.commandExists('guessit')) { + const stdout = deps.runGuessit(mediaPath); + if (typeof stdout === 'string') { + const parsed = parseAniSkipGuessitJson(stdout, mediaPath); + if (parsed) return parsed; + } + } + + const baseName = path.basename(mediaPath, path.extname(mediaPath)); + const pathTitle = inferTitleFromPath(mediaPath); + const fallbackTitle = pathTitle || cleanupTitle(baseName) || baseName; + return { + title: fallbackTitle, + season: detectSeasonFromNameOrDir(mediaPath), + episode: detectEpisodeFromName(baseName), + source: 'fallback', + }; +} + +function sanitizeScriptOptValue(value: string): string { + return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim(); +} + +export function buildSubminerScriptOpts( + appPath: string, + socketPath: string, + aniSkipMetadata: AniSkipMetadata | null, +): string { + const parts = [ + `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, + `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, + ]; + 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}`); + } + return parts.join(','); +} diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 2be241d..5e06471 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -6,6 +6,7 @@ import { spawn, spawnSync } from 'node:child_process'; import type { LogLevel, Backend, Args, MpvTrack } from './types.js'; import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; import { log, fail, getMpvLogPath } from './log.js'; +import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js'; import { commandExists, isExecutable, @@ -472,7 +473,17 @@ export function startMpv( if (preloadedSubtitles?.secondaryPath) { mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`); } - mpvArgs.push(`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`); + const aniSkipMetadata = + targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null; + const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); + 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(`--log-file=${getMpvLogPath()}`); try { diff --git a/plugin/subminer.conf b/plugin/subminer.conf index 979e7f8..ec489d8 100644 --- a/plugin/subminer.conf +++ b/plugin/subminer.conf @@ -40,6 +40,34 @@ osd_messages=yes # Log level for plugin and SubMiner binary: debug, info, warn, error log_level=info +# Enable AniSkip intro detection + markers. +aniskip_enabled=yes + +# Force title (optional). Launcher fills this from guessit when available. +aniskip_title= + +# Force season (optional). Launcher fills this from guessit when available. +aniskip_season= + +# Force MAL id (optional). Leave blank for title lookup. +aniskip_mal_id= + +# Force episode number (optional). Leave blank for filename/title detection. +aniskip_episode= + +# Show intro skip OSD button while inside OP range. +aniskip_show_button=yes + +# OSD text shown for intro skip action. +# `%s` is replaced by keybinding. +aniskip_button_text=You can skip by pressing %s + +# Keybinding to execute intro skip when button is visible. +aniskip_button_key=y-k + +# OSD hint duration in seconds (shown during first 3s of intro). +aniskip_button_duration=3 + # MPV keybindings provided by plugin/subminer.lua: # y-s start, y-S stop, y-t toggle visible overlay # y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay diff --git a/plugin/subminer.lua b/plugin/subminer.lua index c3fb069..e37c7c6 100644 --- a/plugin/subminer.lua +++ b/plugin/subminer.lua @@ -65,6 +65,15 @@ local opts = { auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden osd_messages = true, log_level = "info", + aniskip_enabled = true, + aniskip_title = "", + aniskip_season = "", + aniskip_mal_id = "", + aniskip_episode = "", + aniskip_show_button = true, + aniskip_button_text = "You can skip by pressing %s", + aniskip_button_key = "y-k", + aniskip_button_duration = 3, } options.read_options(opts, "subminer") @@ -87,6 +96,15 @@ local state = { clear_timer = nil, last_hover_update_ts = 0, }, + aniskip = { + mal_id = nil, + title = nil, + episode = nil, + intro_start = nil, + intro_end = nil, + found = false, + prompt_shown = false, + }, } local HOVER_MESSAGE_NAME = "subminer-hover-token" @@ -138,6 +156,539 @@ local function show_osd(message) end 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 run_json_curl(url) + local result = mp.command_native({ + name = "subprocess", + args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url }, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then + return nil, result and result.stderr or "curl failed" + end + local parsed, parse_error = utils.parse_json(result.stdout) + if type(parsed) ~= "table" then + return nil, parse_error or "invalid json" + end + return parsed, nil +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 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 + +local function 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 + -- Require strong multi-token agreement to avoid false positives like "Shadow Skill". + if coverage >= 0.8 then + score = score + 30 + elseif coverage >= 0.6 then + score = score + 10 + else + score = score - 50 + end + else + if coverage >= 1 then + score = score + 10 + end + 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 + +local function 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 + +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 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 + return candidate_title, episode, forced_season +end + +local function resolve_mal_id(title, season) + local forced_mal_id = tonumber(opts.aniskip_mal_id) + if forced_mal_id and forced_mal_id > 0 then + return forced_mal_id, "(forced-mal-id)" + end + if type(title) == "string" and title:match("^%d+$") then + local numeric = tonumber(title) + if numeric and numeric > 0 then + return numeric, title + end + end + if type(title) ~= "string" or title == "" then + return nil, nil + end + + local lookup = title + if season and season > 1 then + lookup = string.format("%s Season %d", lookup, season) + end + local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup) + local mal_json, mal_error = run_json_curl(mal_url) + if not mal_json then + subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error)) + return nil, lookup + end + local categories = mal_json.categories + if type(categories) ~= "table" then + return nil, lookup + end + for _, category in ipairs(categories) do + if type(category) == "table" and type(category.items) == "table" then + for _, item in ipairs(category.items) do + if type(item) == "table" and tonumber(item.id) then + subminer_log( + "info", + "aniskip", + string.format( + 'MAL candidate selected (first result): id=%s name="%s" season_hint=%s', + tostring(item.id), + tostring(item.name or ""), + tostring(season or "-") + ) + ) + return tonumber(item.id), lookup + end + end + end + end + return nil, lookup +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 clear_aniskip_state() + 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 + remove_aniskip_chapters() +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 "y-k" + 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 %d, ep %d)", intro_start, intro_end, mal_id, episode) + ) + return true + end + end + end + return false +end + +local function fetch_aniskip_for_current_media() + clear_aniskip_state() + if not opts.aniskip_enabled then + return + end + local title, episode, season = resolve_title_and_episode() + 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(title) + push_lookup_title(media_title_fallback) + push_lookup_title(filename_fallback) + push_lookup_title(path_fallback) + + subminer_log( + "info", + "aniskip", + string.format( + 'Query context: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)', + 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 + ) + ) + local mal_id, mal_lookup = nil, nil + for index, lookup_title in ipairs(lookup_titles) do + subminer_log( + "info", + "aniskip", + string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title) + ) + local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season) + if attempt_mal_id then + mal_id = attempt_mal_id + mal_lookup = attempt_lookup + break + end + mal_lookup = attempt_lookup or mal_lookup + 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 + 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('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url) + ) + local payload, fetch_error = run_json_curl(url) + if not payload then + subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error)) + return + end + if payload.found ~= true then + subminer_log("info", "aniskip", "AniSkip: no skip windows found") + return + end + if not apply_aniskip_payload(mal_id, title, episode, payload) then + subminer_log("info", "aniskip", "AniSkip payload did not include OP interval") + end +end + local function to_hex_color(input) if type(input) ~= "string" then return nil @@ -1214,6 +1765,8 @@ check_status = function() end local function on_file_loaded() + clear_aniskip_state() + fetch_aniskip_for_current_media() state.binary_path = find_binary() if state.binary_path then state.binary_available = true @@ -1232,6 +1785,7 @@ local function on_file_loaded() end local function on_shutdown() + clear_aniskip_state() clear_hover_overlay() if (state.overlay_running or state.texthooker_running) and state.binary_available then subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process") @@ -1251,6 +1805,12 @@ local function register_keybindings() mp.add_key_binding("y-o", "subminer-options", open_options) mp.add_key_binding("y-r", "subminer-restart", restart_overlay) mp.add_key_binding("y-c", "subminer-status", check_status) + if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then + mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", skip_intro_now) + end + if opts.aniskip_button_key ~= "y-k" then + mp.add_key_binding("y-k", "subminer-skip-intro-fallback", skip_intro_now) + end end local function register_script_messages() @@ -1264,6 +1824,8 @@ local function register_script_messages() mp.register_script_message("subminer-options", open_options) mp.register_script_message("subminer-restart", restart_overlay) mp.register_script_message("subminer-status", check_status) + mp.register_script_message("subminer-aniskip-refresh", fetch_aniskip_for_current_media) + mp.register_script_message("subminer-skip-intro", skip_intro_now) mp.register_script_message(HOVER_MESSAGE_NAME, function(payload_json) handle_hover_message(payload_json) end) @@ -1281,12 +1843,18 @@ local function init() mp.register_event("file-loaded", clear_hover_overlay) mp.register_event("end-file", clear_hover_overlay) mp.register_event("shutdown", clear_hover_overlay) + mp.register_event("end-file", clear_aniskip_state) + mp.register_event("shutdown", clear_aniskip_state) mp.add_hook("on_unload", 10, function() clear_hover_overlay() + clear_aniskip_state() end) mp.observe_property("sub-start", "native", function() clear_hover_overlay() end) + mp.observe_property("time-pos", "number", function() + update_intro_button_visibility() + end) subminer_log("info", "lifecycle", "SubMiner plugin loaded") end