mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 06:22:44 -08:00
fix(plugin): gate aniskip lookups to subminer contexts
This commit is contained in:
@@ -10,6 +10,8 @@ references:
|
|||||||
- plugin/subminer/main.lua
|
- plugin/subminer/main.lua
|
||||||
- plugin/subminer/bootstrap.lua
|
- plugin/subminer/bootstrap.lua
|
||||||
- plugin/subminer/process.lua
|
- plugin/subminer/process.lua
|
||||||
|
- plugin/subminer/aniskip.lua
|
||||||
|
- plugin/subminer/environment.lua
|
||||||
- plugin/subminer/lifecycle.lua
|
- plugin/subminer/lifecycle.lua
|
||||||
- plugin/subminer/messages.lua
|
- plugin/subminer/messages.lua
|
||||||
- plugin/subminer/ui.lua
|
- plugin/subminer/ui.lua
|
||||||
@@ -40,6 +42,8 @@ Scope: Replace monolithic `plugin/subminer.lua` with modular plugin runtime; opt
|
|||||||
Delivered behavior:
|
Delivered behavior:
|
||||||
- Full plugin cutover to `plugin/subminer/main.lua` + module directory (no runtime compatibility shim with old monolith file).
|
- Full plugin cutover to `plugin/subminer/main.lua` + module directory (no runtime compatibility shim with old monolith file).
|
||||||
- Process/control command path moved toward async subprocess usage for non-start actions (`stop`, `toggle`, `settings`, restart stop leg), reducing synchronous blocking in mpv script runtime.
|
- Process/control command path moved toward async subprocess usage for non-start actions (`stop`, `toggle`, `settings`, restart stop leg), reducing synchronous blocking in mpv script runtime.
|
||||||
|
- AniSkip path guarded: lookup runs only in SubMiner context (launcher metadata, explicit script-message refresh, or detected running app), instead of every opened file.
|
||||||
|
- AniSkip lookup pipeline moved to async subprocess calls (no sync `ps`/`curl` on `file-loaded`) with deferred fetch after auto-start and session-level MAL/title/payload caching.
|
||||||
- Startup/runtime loading updated with lazy module initialization via bootstrap proxies.
|
- Startup/runtime loading updated with lazy module initialization via bootstrap proxies.
|
||||||
- Plugin install flow updated to copy `plugin/subminer/` directory and remove legacy `~/.config/mpv/scripts/subminer.lua` file.
|
- Plugin install flow updated to copy `plugin/subminer/` directory and remove legacy `~/.config/mpv/scripts/subminer.lua` file.
|
||||||
- Added plugin gate script wiring to package scripts (`test:plugin:src`) and launcher test flow.
|
- Added plugin gate script wiring to package scripts (`test:plugin:src`) and launcher test flow.
|
||||||
@@ -48,25 +52,24 @@ Delivered behavior:
|
|||||||
|
|
||||||
Risk/impact context:
|
Risk/impact context:
|
||||||
- mpv plugin loading path changed from single-file to module directory; packaging/install paths must stay consistent with release assets.
|
- mpv plugin loading path changed from single-file to module directory; packaging/install paths must stay consistent with release assets.
|
||||||
- Async control path changes reduce blocking but can surface timing differences; regression checks added for cold start and launcher smoke behavior.
|
- Async control/AniSkip path changes reduce blocking but can surface timing differences; regression checks added for cold start, file-load gating, and explicit refresh behavior.
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
## Final Summary
|
## Final Summary
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
Startup fixed-sleep removal delivered in launcher + plugin start paths:
|
AniSkip gate/async update delivered in plugin runtime:
|
||||||
- `launcher/mpv.ts:startOverlay` no longer blocks on hard `2000ms`; now uses bounded readiness/event checks:
|
- `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger.
|
||||||
- bounded wait for mpv IPC socket readiness
|
- `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches.
|
||||||
- bounded wait for spawned overlay command settle (`exit`/`error`)
|
- `plugin/subminer/environment.lua`: async app-running detection with short cache.
|
||||||
- `plugin/subminer/process.lua` removed fixed `0.35s` texthooker delay and fixed `0.6s` startup visibility delay.
|
- `plugin/subminer/messages.lua`: explicit script-message trigger wiring.
|
||||||
- Overlay start now uses short bounded retries on non-ready start failures, then applies startup overlay visibility once start succeeds.
|
|
||||||
|
|
||||||
Regression coverage added:
|
Regression coverage updated:
|
||||||
- `launcher/mpv.test.ts` adds guard that `startOverlay` resolves quickly when readiness arrives.
|
- `scripts/test-plugin-start-gate.lua` now verifies:
|
||||||
- `scripts/test-plugin-process-start-retries.lua` asserts retry behavior and guards against reintroducing fixed `0.35/0.6` timers.
|
- no sync `ps`/`curl` on non-context file load
|
||||||
|
- no AniSkip network lookup on non-context file load
|
||||||
|
- script-message refresh forces async AniSkip lookup
|
||||||
|
|
||||||
Validation run:
|
Validation run:
|
||||||
- `bun test launcher/mpv.test.ts` pass.
|
- `bun run test:plugin:src` pass.
|
||||||
- `lua scripts/test-plugin-process-start-retries.lua` pass.
|
|
||||||
- Existing broader plugin gate suite still has unrelated failure in current tree (`scripts/test-plugin-start-gate.lua` AniSkip async curl assertion).
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MPV Plugin
|
# MPV Plugin
|
||||||
|
|
||||||
The SubMiner mpv plugin (`subminer.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
|
|||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.config/SubMiner
|
mkdir -p ~/.config/SubMiner
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
|
mkdir -p ~/.config/mpv/scripts/subminer
|
||||||
|
mkdir -p ~/.config/mpv/script-opts
|
||||||
|
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
|
|
||||||
# Or from source checkout: make install-plugin
|
# Or from source checkout: make install-plugin
|
||||||
@@ -192,7 +194,12 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
|
|
||||||
## AniSkip Intro Skip
|
## AniSkip Intro Skip
|
||||||
|
|
||||||
- On file load, plugin resolves title + episode, resolves MAL id, then calls AniSkip API.
|
- AniSkip lookups are gated. The plugin only runs lookup when:
|
||||||
|
- SubMiner launcher metadata is present, or
|
||||||
|
- SubMiner app process is already running, or
|
||||||
|
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||||
|
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||||
|
- MAL/title resolution is cached for the current mpv session.
|
||||||
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
- 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`).
|
- 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.
|
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||||
@@ -201,7 +208,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
|
|
||||||
## 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, then defers AniSkip lookup until after startup delay.
|
||||||
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
||||||
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
||||||
|
|
||||||
|
|||||||
577
plugin/subminer/aniskip.lua
Normal file
577
plugin/subminer/aniskip.lua
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
local M = {}
|
||||||
|
local matcher = require("aniskip_match")
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local request_generation = 0
|
||||||
|
local mal_lookup_cache = {}
|
||||||
|
local payload_cache = {}
|
||||||
|
local title_context_cache = {}
|
||||||
|
|
||||||
|
local 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_async(url, callback)
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
|
||||||
|
local detail = error or (result and result.stderr) or "curl failed"
|
||||||
|
callback(nil, detail)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(result.stdout)
|
||||||
|
if type(parsed) ~= "table" then
|
||||||
|
callback(nil, parse_error or "invalid json")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(parsed, nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_episode_hint(text)
|
||||||
|
if type(text) ~= "string" or text == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local patterns = {
|
||||||
|
"[Ss]%d+[Ee](%d+)",
|
||||||
|
"[Ee][Pp]?[%s%._%-]*(%d+)",
|
||||||
|
"[%s%._%-]+(%d+)[%s%._%-]+",
|
||||||
|
}
|
||||||
|
for _, pattern in ipairs(patterns) do
|
||||||
|
local token = text:match(pattern)
|
||||||
|
if token then
|
||||||
|
local episode = tonumber(token)
|
||||||
|
if episode and episode > 0 and episode < 10000 then
|
||||||
|
return episode
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cleanup_title(raw)
|
||||||
|
if type(raw) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cleaned = raw
|
||||||
|
cleaned = cleaned:gsub("%b[]", " ")
|
||||||
|
cleaned = cleaned:gsub("%b()", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[%._%-]+", " ")
|
||||||
|
cleaned = cleaned:gsub("%s+", " ")
|
||||||
|
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||||
|
if cleaned == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_show_title_from_path(media_path)
|
||||||
|
if type(media_path) ~= "string" or media_path == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local normalized = media_path:gsub("\\", "/")
|
||||||
|
local segments = {}
|
||||||
|
for segment in normalized:gmatch("[^/]+") do
|
||||||
|
segments[#segments + 1] = segment
|
||||||
|
end
|
||||||
|
for index = 1, #segments do
|
||||||
|
local segment = segments[index] or ""
|
||||||
|
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
|
||||||
|
local prior = segments[index - 1]
|
||||||
|
local cleaned = cleanup_title(prior or "")
|
||||||
|
if cleaned and cleaned ~= "" then
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_title_and_episode()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
local media_title = mp.get_property("media-title")
|
||||||
|
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
|
||||||
|
local path = mp.get_property("path") or ""
|
||||||
|
local cache_key = table.concat({
|
||||||
|
tostring(forced_title or ""),
|
||||||
|
tostring(forced_season or ""),
|
||||||
|
tostring(forced_episode or ""),
|
||||||
|
tostring(media_title or ""),
|
||||||
|
tostring(filename or ""),
|
||||||
|
tostring(path or ""),
|
||||||
|
}, "\31")
|
||||||
|
local cached = title_context_cache[cache_key]
|
||||||
|
if type(cached) == "table" then
|
||||||
|
return cached.title, cached.episode, cached.season
|
||||||
|
end
|
||||||
|
local path_show_title = extract_show_title_from_path(path)
|
||||||
|
local candidate_title = nil
|
||||||
|
if path_show_title and path_show_title ~= "" then
|
||||||
|
candidate_title = path_show_title
|
||||||
|
elseif forced_title ~= "" then
|
||||||
|
candidate_title = forced_title
|
||||||
|
else
|
||||||
|
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
|
||||||
|
end
|
||||||
|
local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1
|
||||||
|
title_context_cache[cache_key] = {
|
||||||
|
title = candidate_title,
|
||||||
|
episode = episode,
|
||||||
|
season = forced_season,
|
||||||
|
}
|
||||||
|
return candidate_title, episode, forced_season
|
||||||
|
end
|
||||||
|
|
||||||
|
local function select_best_mal_item(items, title, season)
|
||||||
|
if type(items) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local best_item = nil
|
||||||
|
local best_score = -math.huge
|
||||||
|
for _, item in ipairs(items) do
|
||||||
|
if type(item) == "table" and tonumber(item.id) then
|
||||||
|
local candidate_name = tostring(item.name or "")
|
||||||
|
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
|
||||||
|
if score > best_score then
|
||||||
|
best_score = score
|
||||||
|
best_item = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best_item
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_id_async(title, season, request_id, callback)
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
callback(forced_mal_id, "(forced-mal-id)")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(title) == "string" and title:match("^%d+$") then
|
||||||
|
local numeric = tonumber(title)
|
||||||
|
if numeric and numeric > 0 then
|
||||||
|
callback(numeric, title)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(title) ~= "string" or title == "" then
|
||||||
|
callback(nil, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lookup = title
|
||||||
|
if season and season > 1 then
|
||||||
|
lookup = string.format("%s Season %d", lookup, season)
|
||||||
|
end
|
||||||
|
local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-"))
|
||||||
|
local cached = mal_lookup_cache[cache_key]
|
||||||
|
if cached ~= nil then
|
||||||
|
if cached == false then
|
||||||
|
callback(nil, lookup)
|
||||||
|
else
|
||||||
|
callback(cached, lookup)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
|
||||||
|
run_json_curl_async(mal_url, function(mal_json, mal_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not mal_json then
|
||||||
|
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local categories = mal_json.categories
|
||||||
|
if type(categories) ~= "table" then
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local all_items = {}
|
||||||
|
for _, category in ipairs(categories) do
|
||||||
|
if type(category) == "table" and type(category.items) == "table" then
|
||||||
|
for _, item in ipairs(category.items) do
|
||||||
|
all_items[#all_items + 1] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local best_item = select_best_mal_item(all_items, title, season)
|
||||||
|
if best_item and tonumber(best_item.id) then
|
||||||
|
local matched_id = tonumber(best_item.id)
|
||||||
|
mal_lookup_cache[cache_key] = matched_id
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
|
||||||
|
tostring(best_item.id),
|
||||||
|
tostring(best_item.name or ""),
|
||||||
|
tostring(season or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callback(matched_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_intro_chapters(intro_start, intro_end)
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
local chapters = {}
|
||||||
|
if type(current) == "table" then
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) ~= "string" or not title:match("^AniSkip ") then
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
|
||||||
|
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
|
||||||
|
table.sort(chapters, function(a, b)
|
||||||
|
local a_time = type(a) == "table" and tonumber(a.time) or 0
|
||||||
|
local b_time = type(b) == "table" and tonumber(b.time) or 0
|
||||||
|
return a_time < b_time
|
||||||
|
end)
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remove_aniskip_chapters()
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
if type(current) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local chapters = {}
|
||||||
|
local changed = false
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) == "string" and title:match("^AniSkip ") then
|
||||||
|
changed = true
|
||||||
|
else
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if changed then
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reset_aniskip_fields()
|
||||||
|
state.aniskip.prompt_shown = false
|
||||||
|
state.aniskip.found = false
|
||||||
|
state.aniskip.mal_id = nil
|
||||||
|
state.aniskip.title = nil
|
||||||
|
state.aniskip.episode = nil
|
||||||
|
state.aniskip.intro_start = nil
|
||||||
|
state.aniskip.intro_end = nil
|
||||||
|
remove_aniskip_chapters()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_aniskip_state()
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
reset_aniskip_fields()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function skip_intro_now()
|
||||||
|
if not state.aniskip.found then
|
||||||
|
show_osd("Intro skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local intro_start = state.aniskip.intro_start
|
||||||
|
local intro_end = state.aniskip.intro_end
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
show_osd("Intro markers missing")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
show_osd("Skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local epsilon = 0.35
|
||||||
|
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
|
||||||
|
show_osd("Skip intro only during intro")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mp.set_property_number("time-pos", intro_end)
|
||||||
|
show_osd("Skipped intro")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function update_intro_button_visibility()
|
||||||
|
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
|
||||||
|
local intro_start = state.aniskip.intro_start or -1
|
||||||
|
local hint_window_end = intro_start + 3
|
||||||
|
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
||||||
|
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "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 is_launcher_context()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
if forced_title ~= "" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
if forced_episode and forced_episode > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
if forced_season and forced_season > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_fetch_aniskip_async(trigger_source, callback)
|
||||||
|
if trigger_source == "script-message" or trigger_source == "overlay-start" then
|
||||||
|
callback(true, trigger_source)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if is_launcher_context() then
|
||||||
|
callback(true, "launcher-context")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(environment.is_subminer_app_running_async) == "function" then
|
||||||
|
environment.is_subminer_app_running_async(function(running)
|
||||||
|
if running then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
else
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if environment.is_subminer_app_running() then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_lookup_titles(primary_title)
|
||||||
|
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
|
||||||
|
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
|
||||||
|
local path_fallback = cleanup_title(mp.get_property("path") or "")
|
||||||
|
local lookup_titles = {}
|
||||||
|
local seen_titles = {}
|
||||||
|
local function push_lookup_title(candidate)
|
||||||
|
if type(candidate) ~= "string" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local key = trimmed:lower()
|
||||||
|
if seen_titles[key] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
seen_titles[key] = true
|
||||||
|
lookup_titles[#lookup_titles + 1] = trimmed
|
||||||
|
end
|
||||||
|
push_lookup_title(primary_title)
|
||||||
|
push_lookup_title(media_title_fallback)
|
||||||
|
push_lookup_title(filename_fallback)
|
||||||
|
push_lookup_title(path_fallback)
|
||||||
|
return lookup_titles
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup)
|
||||||
|
local current_index = index or 1
|
||||||
|
local current_lookup = last_lookup
|
||||||
|
if current_index > #lookup_titles then
|
||||||
|
callback(nil, current_lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local lookup_title = lookup_titles[current_index]
|
||||||
|
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title))
|
||||||
|
resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if mal_id then
|
||||||
|
callback(mal_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback)
|
||||||
|
local payload_cache_key = string.format("%d:%d", mal_id, episode)
|
||||||
|
local cached_payload = payload_cache[payload_cache_key]
|
||||||
|
if cached_payload ~= nil then
|
||||||
|
if cached_payload == false then
|
||||||
|
callback(nil, nil, true)
|
||||||
|
else
|
||||||
|
callback(cached_payload, nil, true)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
|
||||||
|
subminer_log("info", "aniskip", string.format("AniSkip URL=%s", url))
|
||||||
|
run_json_curl_async(url, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
callback(nil, fetch_error, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if payload.found ~= true then
|
||||||
|
payload_cache[payload_cache_key] = false
|
||||||
|
callback(nil, nil, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
payload_cache[payload_cache_key] = payload
|
||||||
|
callback(payload, nil, false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
|
if not opts.aniskip_enabled then
|
||||||
|
clear_aniskip_state()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
should_fetch_aniskip_async(trigger, function(allowed, reason)
|
||||||
|
if not allowed then
|
||||||
|
subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
local request_id = request_generation
|
||||||
|
reset_aniskip_fields()
|
||||||
|
local title, episode, season = resolve_title_and_episode()
|
||||||
|
local lookup_titles = resolve_lookup_titles(title)
|
||||||
|
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
|
||||||
|
tostring(trigger),
|
||||||
|
tostring(reason or "-"),
|
||||||
|
tostring(title or ""),
|
||||||
|
tostring(season or "-"),
|
||||||
|
tostring(episode or "-"),
|
||||||
|
tostring(opts.aniskip_title or ""),
|
||||||
|
tostring(opts.aniskip_season or "-"),
|
||||||
|
tostring(opts.aniskip_episode or "-"),
|
||||||
|
tostring(opts.aniskip_mal_id or "-"),
|
||||||
|
#lookup_titles
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not mal_id then
|
||||||
|
subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or "")))
|
||||||
|
fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
if fetch_error then
|
||||||
|
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
|
||||||
|
else
|
||||||
|
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
clear_aniskip_state = clear_aniskip_state,
|
||||||
|
skip_intro_now = skip_intro_now,
|
||||||
|
update_intro_button_visibility = update_intro_button_visibility,
|
||||||
|
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
210
plugin/subminer/environment.lua
Normal file
210
plugin/subminer/environment.lua
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
|
||||||
|
local detected_backend = nil
|
||||||
|
local app_running_cache_value = nil
|
||||||
|
local app_running_cache_time = nil
|
||||||
|
local app_running_check_inflight = false
|
||||||
|
local app_running_waiters = {}
|
||||||
|
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
||||||
|
|
||||||
|
local function is_windows()
|
||||||
|
return package.config:sub(1, 1) == "\\"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_macos()
|
||||||
|
local platform = mp.get_property("platform") or ""
|
||||||
|
if platform == "macos" or platform == "darwin" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local ostype = os.getenv("OSTYPE") or ""
|
||||||
|
return ostype:find("darwin") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function default_socket_path()
|
||||||
|
if is_windows() then
|
||||||
|
return "\\\\.\\pipe\\subminer-socket"
|
||||||
|
end
|
||||||
|
return "/tmp/subminer-socket"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_linux()
|
||||||
|
return not is_windows() and not is_macos()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function now_seconds()
|
||||||
|
if type(mp.get_time) == "function" then
|
||||||
|
local value = tonumber(mp.get_time())
|
||||||
|
if value then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_list_has_subminer(raw_process_list)
|
||||||
|
if type(raw_process_list) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local process_list = raw_process_list:lower()
|
||||||
|
for line in process_list:gmatch("[^\n]+") do
|
||||||
|
if is_windows() then
|
||||||
|
local image = line:match('^"([^"]+)","')
|
||||||
|
if not image then
|
||||||
|
image = line:match('^"([^"]+)"')
|
||||||
|
end
|
||||||
|
if not image then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
|
||||||
|
if not argv0 then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
local exe = argv0:match("([^/\\]+)$") or argv0
|
||||||
|
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_scan_command()
|
||||||
|
if is_windows() then
|
||||||
|
return { "tasklist", "/FO", "CSV", "/NH" }
|
||||||
|
end
|
||||||
|
return { "ps", "-A", "-o", "args=" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_process_running()
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
})
|
||||||
|
if not result or result.status ~= 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush_app_running_waiters(value)
|
||||||
|
local waiters = app_running_waiters
|
||||||
|
app_running_waiters = {}
|
||||||
|
for _, waiter in ipairs(waiters) do
|
||||||
|
waiter(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running_async(callback, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local force_refresh = opts.force_refresh == true
|
||||||
|
local now = now_seconds()
|
||||||
|
if not force_refresh and app_running_cache_value ~= nil and app_running_cache_time ~= nil then
|
||||||
|
if (now - app_running_cache_time) <= APP_RUNNING_CACHE_TTL_SECONDS then
|
||||||
|
callback(app_running_cache_value)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
app_running_waiters[#app_running_waiters + 1] = callback
|
||||||
|
if app_running_check_inflight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
app_running_check_inflight = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
}, function(success, result)
|
||||||
|
app_running_check_inflight = false
|
||||||
|
local running = false
|
||||||
|
if success and result and result.status == 0 then
|
||||||
|
running = process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
flush_app_running_waiters(running)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running()
|
||||||
|
local running = is_subminer_process_running()
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
return running
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_subminer_app_running_cache(running)
|
||||||
|
app_running_cache_value = running == true
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detect_backend()
|
||||||
|
if detected_backend then
|
||||||
|
return detected_backend
|
||||||
|
end
|
||||||
|
|
||||||
|
local backend = nil
|
||||||
|
local subminer_log = ctx.log and ctx.log.subminer_log or function() end
|
||||||
|
|
||||||
|
if is_macos() then
|
||||||
|
backend = "macos"
|
||||||
|
elseif is_windows() then
|
||||||
|
backend = nil
|
||||||
|
elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then
|
||||||
|
backend = "hyprland"
|
||||||
|
elseif os.getenv("SWAYSOCK") then
|
||||||
|
backend = "sway"
|
||||||
|
elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then
|
||||||
|
backend = "x11"
|
||||||
|
else
|
||||||
|
subminer_log("warn", "backend", "Could not detect window manager, falling back to x11")
|
||||||
|
backend = "x11"
|
||||||
|
end
|
||||||
|
|
||||||
|
detected_backend = backend
|
||||||
|
if backend then
|
||||||
|
subminer_log("info", "backend", "Detected backend: " .. backend)
|
||||||
|
else
|
||||||
|
subminer_log("info", "backend", "No backend detected")
|
||||||
|
end
|
||||||
|
return backend
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_windows = is_windows,
|
||||||
|
is_macos = is_macos,
|
||||||
|
is_linux = is_linux,
|
||||||
|
default_socket_path = default_socket_path,
|
||||||
|
is_subminer_process_running = is_subminer_process_running,
|
||||||
|
is_subminer_app_running = is_subminer_app_running,
|
||||||
|
is_subminer_app_running_async = is_subminer_app_running_async,
|
||||||
|
set_subminer_app_running_cache = set_subminer_app_running_cache,
|
||||||
|
detect_backend = detect_backend,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
82
plugin/subminer/lifecycle.lua
Normal file
82
plugin/subminer/lifecycle.lua
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
|
||||||
|
local delay = tonumber(delay_seconds) or 0
|
||||||
|
mp.add_timeout(delay, function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_file_loaded()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
|
||||||
|
local should_auto_start = options_helper.coerce_bool(opts.auto_start, false)
|
||||||
|
if should_auto_start then
|
||||||
|
process.start_overlay()
|
||||||
|
-- Give the overlay process a moment to initialize before querying AniSkip.
|
||||||
|
schedule_aniskip_fetch("overlay-start", 0.8)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_shutdown()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
if state.overlay_running or state.texthooker_running then
|
||||||
|
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
|
||||||
|
show_osd("Shutting down...")
|
||||||
|
process.stop_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_lifecycle_hooks()
|
||||||
|
mp.register_event("file-loaded", on_file_loaded)
|
||||||
|
mp.register_event("shutdown", on_shutdown)
|
||||||
|
mp.register_event("file-loaded", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.add_hook("on_unload", 10, function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.observe_property("sub-start", "native", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.observe_property("time-pos", "number", function()
|
||||||
|
aniskip.update_intro_button_visibility()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
on_file_loaded = on_file_loaded,
|
||||||
|
on_shutdown = on_shutdown,
|
||||||
|
register_lifecycle_hooks = register_lifecycle_hooks,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
51
plugin/subminer/messages.lua
Normal file
51
plugin/subminer/messages.lua
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local ui = ctx.ui
|
||||||
|
|
||||||
|
local function register_script_messages()
|
||||||
|
mp.register_script_message("subminer-start", function(...)
|
||||||
|
process.start_overlay_from_script_message(...)
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-stop", function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-toggle", function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-menu", function()
|
||||||
|
ui.show_menu()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-options", function()
|
||||||
|
process.open_options()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-restart", function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-status", function()
|
||||||
|
process.check_status()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-aniskip-refresh", function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media("script-message")
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-skip-intro", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
register_script_messages = register_script_messages,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -3,7 +3,9 @@ local function run_plugin_scenario(config)
|
|||||||
|
|
||||||
local recorded = {
|
local recorded = {
|
||||||
async_calls = {},
|
async_calls = {},
|
||||||
|
sync_calls = {},
|
||||||
script_messages = {},
|
script_messages = {},
|
||||||
|
events = {},
|
||||||
osd = {},
|
osd = {},
|
||||||
logs = {},
|
logs = {},
|
||||||
}
|
}
|
||||||
@@ -34,7 +36,12 @@ local function run_plugin_scenario(config)
|
|||||||
return config.chapter_list or {}
|
return config.chapter_list or {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function mp.get_script_directory()
|
||||||
|
return "plugin/subminer"
|
||||||
|
end
|
||||||
|
|
||||||
function mp.command_native(command)
|
function mp.command_native(command)
|
||||||
|
recorded.sync_calls[#recorded.sync_calls + 1] = command
|
||||||
local args = command.args or {}
|
local args = command.args or {}
|
||||||
if args[1] == "ps" then
|
if args[1] == "ps" then
|
||||||
return {
|
return {
|
||||||
@@ -44,6 +51,13 @@ local function run_plugin_scenario(config)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
if args[1] == "curl" then
|
if args[1] == "curl" then
|
||||||
|
local url = args[#args] or ""
|
||||||
|
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||||
|
return { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }
|
||||||
|
end
|
||||||
|
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||||
|
return { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }
|
||||||
|
end
|
||||||
return { status = 0, stdout = "{}", stderr = "" }
|
return { status = 0, stdout = "{}", stderr = "" }
|
||||||
end
|
end
|
||||||
return { status = 0, stdout = "", stderr = "" }
|
return { status = 0, stdout = "", stderr = "" }
|
||||||
@@ -52,6 +66,22 @@ local function run_plugin_scenario(config)
|
|||||||
function mp.command_native_async(command, callback)
|
function mp.command_native_async(command, callback)
|
||||||
recorded.async_calls[#recorded.async_calls + 1] = command
|
recorded.async_calls[#recorded.async_calls + 1] = command
|
||||||
if callback then
|
if callback then
|
||||||
|
local args = command.args or {}
|
||||||
|
if args[1] == "ps" then
|
||||||
|
callback(true, { status = 0, stdout = config.process_list or "", stderr = "" }, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if args[1] == "curl" then
|
||||||
|
local url = args[#args] or ""
|
||||||
|
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||||
|
callback(true, { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||||
|
callback(true, { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -67,12 +97,18 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function mp.add_key_binding(_keys, _name, _fn) end
|
function mp.add_key_binding(_keys, _name, _fn) end
|
||||||
function mp.register_event(_name, _fn) end
|
function mp.register_event(name, fn)
|
||||||
|
if recorded.events[name] == nil then
|
||||||
|
recorded.events[name] = {}
|
||||||
|
end
|
||||||
|
recorded.events[name][#recorded.events[name] + 1] = fn
|
||||||
|
end
|
||||||
function mp.add_hook(_name, _prio, _fn) end
|
function mp.add_hook(_name, _prio, _fn) end
|
||||||
function mp.observe_property(_name, _kind, _fn) end
|
function mp.observe_property(_name, _kind, _fn) end
|
||||||
function mp.osd_message(message, _duration)
|
function mp.osd_message(message, _duration)
|
||||||
recorded.osd[#recorded.osd + 1] = message
|
recorded.osd[#recorded.osd + 1] = message
|
||||||
end
|
end
|
||||||
|
function mp.set_osd_ass(...) end
|
||||||
function mp.get_time()
|
function mp.get_time()
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
@@ -90,8 +126,8 @@ local function run_plugin_scenario(config)
|
|||||||
local utils = {}
|
local utils = {}
|
||||||
|
|
||||||
function options.read_options(target, _name)
|
function options.read_options(target, _name)
|
||||||
if config.socket_path then
|
for key, value in pairs(config.option_overrides or {}) do
|
||||||
target.socket_path = config.socket_path
|
target[key] = value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -108,7 +144,35 @@ local function run_plugin_scenario(config)
|
|||||||
return table.concat(parts, "/")
|
return table.concat(parts, "/")
|
||||||
end
|
end
|
||||||
|
|
||||||
function utils.parse_json(_json)
|
function utils.parse_json(json)
|
||||||
|
if json == "__MAL_FOUND__" then
|
||||||
|
return {
|
||||||
|
categories = {
|
||||||
|
{
|
||||||
|
items = {
|
||||||
|
{
|
||||||
|
id = 99,
|
||||||
|
name = "Sample Show",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
end
|
||||||
|
if json == "__ANISKIP_FOUND__" then
|
||||||
|
return {
|
||||||
|
found = true,
|
||||||
|
results = {
|
||||||
|
{
|
||||||
|
skip_type = "op",
|
||||||
|
interval = {
|
||||||
|
start_time = 12.3,
|
||||||
|
end_time = 45.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
end
|
||||||
return {}, nil
|
return {}, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -149,7 +213,7 @@ local function run_plugin_scenario(config)
|
|||||||
return utils
|
return utils
|
||||||
end
|
end
|
||||||
|
|
||||||
local ok, err = pcall(dofile, "plugin/subminer.lua")
|
local ok, err = pcall(dofile, "plugin/subminer/main.lua")
|
||||||
if not ok then
|
if not ok then
|
||||||
return nil, err, recorded
|
return nil, err, recorded
|
||||||
end
|
end
|
||||||
@@ -168,6 +232,39 @@ local function find_start_call(async_calls)
|
|||||||
local args = call.args or {}
|
local args = call.args or {}
|
||||||
for i = 1, #args do
|
for i = 1, #args do
|
||||||
if args[i] == "--start" then
|
if args[i] == "--start" then
|
||||||
|
return call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_sync_command(sync_calls, executable)
|
||||||
|
for _, call in ipairs(sync_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
if args[1] == executable then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_async_command(async_calls, executable)
|
||||||
|
for _, call in ipairs(async_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
if args[1] == executable then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_async_curl_for(async_calls, needle)
|
||||||
|
for _, call in ipairs(async_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
if args[1] == "curl" then
|
||||||
|
local url = args[#args] or ""
|
||||||
|
if type(url) == "string" and url:find(needle, 1, true) then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -175,11 +272,22 @@ local function find_start_call(async_calls)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fire_event(recorded, name)
|
||||||
|
local listeners = recorded.events[name] or {}
|
||||||
|
for _, listener in ipairs(listeners) do
|
||||||
|
listener()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local binary_path = "/tmp/subminer-binary"
|
local binary_path = "/tmp/subminer-binary"
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
},
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
},
|
},
|
||||||
@@ -187,7 +295,62 @@ do
|
|||||||
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
|
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
|
||||||
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
||||||
recorded.script_messages["subminer-start"]("texthooker=no")
|
recorded.script_messages["subminer-start"]("texthooker=no")
|
||||||
assert_true(find_start_call(recorded.async_calls), "expected cold-start to invoke --start command when process is absent")
|
assert_true(find_start_call(recorded.async_calls) ~= nil, "expected cold-start to invoke --start command when process is absent")
|
||||||
|
assert_true(
|
||||||
|
not has_sync_command(recorded.sync_calls, "ps"),
|
||||||
|
"expected cold-start start command to avoid synchronous process list scan"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
},
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(not has_sync_command(recorded.sync_calls, "ps"), "file-loaded should avoid synchronous process checks")
|
||||||
|
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should avoid synchronous AniSkip network calls")
|
||||||
|
assert_true(
|
||||||
|
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||||
|
"file-loaded without SubMiner context should skip AniSkip MAL lookup"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
|
||||||
|
"file-loaded without SubMiner context should skip AniSkip API lookup"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
},
|
||||||
|
media_title = "Sample Show S01E01",
|
||||||
|
mal_lookup_stdout = "__MAL_FOUND__",
|
||||||
|
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for script-message AniSkip scenario: " .. tostring(err))
|
||||||
|
assert_true(recorded.script_messages["subminer-aniskip-refresh"] ~= nil, "subminer-aniskip-refresh script message not registered")
|
||||||
|
recorded.script_messages["subminer-aniskip-refresh"]()
|
||||||
|
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "AniSkip refresh should not perform synchronous curl calls")
|
||||||
|
assert_true(has_async_command(recorded.async_calls, "curl"), "AniSkip refresh should perform async curl calls")
|
||||||
|
assert_true(
|
||||||
|
has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||||
|
"AniSkip refresh should perform MAL lookup even when app is not running"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
print("plugin start gate regression tests: OK")
|
print("plugin start gate regression tests: OK")
|
||||||
|
|||||||
Reference in New Issue
Block a user