mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(plugin): add AniSkip intro skip flow with launcher metadata hints
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Action Steps
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #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.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
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`
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
<!-- DOD:BEGIN -->
|
||||||
|
- [ ] #1 Focused plugin smoke validation in mpv.
|
||||||
|
- [ ] #2 Lua parse/load check passes in local environment.
|
||||||
|
- [ ] #3 Task notes capture fallback behavior.
|
||||||
|
<!-- DOD:END -->
|
||||||
@@ -38,6 +38,7 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
|
|||||||
| `y-o` | Open settings window |
|
| `y-o` | Open settings window |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check status |
|
| `y-c` | Check status |
|
||||||
|
| `y-k` | Skip intro (AniSkip) |
|
||||||
|
|
||||||
## Menu
|
## Menu
|
||||||
|
|
||||||
@@ -91,6 +92,29 @@ osd_messages=yes
|
|||||||
|
|
||||||
# Logging level: debug, info, warn, error.
|
# Logging level: debug, info, warn, error.
|
||||||
log_level=info
|
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
|
### Option Reference
|
||||||
@@ -107,6 +131,15 @@ log_level=info
|
|||||||
| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start |
|
| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start |
|
||||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
| `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
|
## Binary Auto-Detection
|
||||||
|
|
||||||
@@ -155,6 +188,8 @@ script-message subminer-menu
|
|||||||
script-message subminer-options
|
script-message subminer-options
|
||||||
script-message subminer-restart
|
script-message subminer-restart
|
||||||
script-message subminer-status
|
script-message subminer-status
|
||||||
|
script-message subminer-aniskip-refresh
|
||||||
|
script-message subminer-skip-intro
|
||||||
```
|
```
|
||||||
|
|
||||||
The `subminer-start` message accepts overrides:
|
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.
|
`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
|
||||||
|
|
||||||
|
- 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
|
## Lifecycle
|
||||||
|
|
||||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay and applies visibility preferences after a short delay.
|
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay and applies visibility preferences after a short delay.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Read first. Keep concise.
|
|||||||
|
|
||||||
| agent_id | alias | mission | status | file | last_update_utc |
|
| 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-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-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` |
|
| `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-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-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-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` |
|
||||||
|
|||||||
@@ -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 (`.../<Show>/Season-1/<file>`).
|
||||||
|
- [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 (`.../<Show>/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`.
|
||||||
@@ -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: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: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-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 (`.../<Show>/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.
|
||||||
|
|||||||
75
launcher/aniskip-metadata.test.ts
Normal file
75
launcher/aniskip-metadata.test.ts
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
196
launcher/aniskip-metadata.ts
Normal file
196
launcher/aniskip-metadata.ts
Normal file
@@ -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(',');
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|||||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail, getMpvLogPath } from './log.js';
|
||||||
|
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
isExecutable,
|
isExecutable,
|
||||||
@@ -472,7 +473,17 @@ export function startMpv(
|
|||||||
if (preloadedSubtitles?.secondaryPath) {
|
if (preloadedSubtitles?.secondaryPath) {
|
||||||
mpvArgs.push(`--sub-file=${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()}`);
|
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,6 +40,34 @@ osd_messages=yes
|
|||||||
# Log level for plugin and SubMiner binary: debug, info, warn, error
|
# Log level for plugin and SubMiner binary: debug, info, warn, error
|
||||||
log_level=info
|
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:
|
# MPV keybindings provided by plugin/subminer.lua:
|
||||||
# y-s start, y-S stop, y-t toggle visible overlay
|
# 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
|
# y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ local opts = {
|
|||||||
auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden
|
auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden
|
||||||
osd_messages = true,
|
osd_messages = true,
|
||||||
log_level = "info",
|
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")
|
options.read_options(opts, "subminer")
|
||||||
@@ -87,6 +96,15 @@ local state = {
|
|||||||
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,
|
||||||
|
found = false,
|
||||||
|
prompt_shown = false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
local HOVER_MESSAGE_NAME = "subminer-hover-token"
|
local HOVER_MESSAGE_NAME = "subminer-hover-token"
|
||||||
@@ -138,6 +156,539 @@ local function show_osd(message)
|
|||||||
end
|
end
|
||||||
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)
|
local function to_hex_color(input)
|
||||||
if type(input) ~= "string" then
|
if type(input) ~= "string" then
|
||||||
return nil
|
return nil
|
||||||
@@ -1214,6 +1765,8 @@ check_status = function()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function on_file_loaded()
|
local function on_file_loaded()
|
||||||
|
clear_aniskip_state()
|
||||||
|
fetch_aniskip_for_current_media()
|
||||||
state.binary_path = find_binary()
|
state.binary_path = find_binary()
|
||||||
if state.binary_path then
|
if state.binary_path then
|
||||||
state.binary_available = true
|
state.binary_available = true
|
||||||
@@ -1232,6 +1785,7 @@ local function on_file_loaded()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function on_shutdown()
|
local function on_shutdown()
|
||||||
|
clear_aniskip_state()
|
||||||
clear_hover_overlay()
|
clear_hover_overlay()
|
||||||
if (state.overlay_running or state.texthooker_running) and state.binary_available then
|
if (state.overlay_running or state.texthooker_running) and state.binary_available then
|
||||||
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
|
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-o", "subminer-options", open_options)
|
||||||
mp.add_key_binding("y-r", "subminer-restart", restart_overlay)
|
mp.add_key_binding("y-r", "subminer-restart", restart_overlay)
|
||||||
mp.add_key_binding("y-c", "subminer-status", check_status)
|
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
|
end
|
||||||
|
|
||||||
local function register_script_messages()
|
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-options", open_options)
|
||||||
mp.register_script_message("subminer-restart", restart_overlay)
|
mp.register_script_message("subminer-restart", restart_overlay)
|
||||||
mp.register_script_message("subminer-status", check_status)
|
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)
|
mp.register_script_message(HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
handle_hover_message(payload_json)
|
handle_hover_message(payload_json)
|
||||||
end)
|
end)
|
||||||
@@ -1281,12 +1843,18 @@ local function init()
|
|||||||
mp.register_event("file-loaded", clear_hover_overlay)
|
mp.register_event("file-loaded", clear_hover_overlay)
|
||||||
mp.register_event("end-file", clear_hover_overlay)
|
mp.register_event("end-file", clear_hover_overlay)
|
||||||
mp.register_event("shutdown", 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()
|
mp.add_hook("on_unload", 10, function()
|
||||||
clear_hover_overlay()
|
clear_hover_overlay()
|
||||||
|
clear_aniskip_state()
|
||||||
end)
|
end)
|
||||||
mp.observe_property("sub-start", "native", function()
|
mp.observe_property("sub-start", "native", function()
|
||||||
clear_hover_overlay()
|
clear_hover_overlay()
|
||||||
end)
|
end)
|
||||||
|
mp.observe_property("time-pos", "number", function()
|
||||||
|
update_intro_button_visibility()
|
||||||
|
end)
|
||||||
|
|
||||||
subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user