From d5f938c4b636a6b9a381ad778406bbf78c530327 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 27 Feb 2026 21:02:39 -0800 Subject: [PATCH] fix(plugin): gate aniskip lookups to subminer contexts --- ...es-and-optimize-startup-command-runtime.md | 29 +- docs/mpv-plugin.md | 15 +- plugin/subminer/aniskip.lua | 577 ++++++++++++++++++ plugin/subminer/environment.lua | 210 +++++++ plugin/subminer/lifecycle.lua | 82 +++ plugin/subminer/messages.lua | 51 ++ scripts/test-plugin-start-gate.lua | 175 +++++- 7 files changed, 1116 insertions(+), 23 deletions(-) create mode 100644 plugin/subminer/aniskip.lua create mode 100644 plugin/subminer/environment.lua create mode 100644 plugin/subminer/lifecycle.lua create mode 100644 plugin/subminer/messages.lua diff --git a/backlog/tasks/task-73 - MPV-plugin-split-into-modules-and-optimize-startup-command-runtime.md b/backlog/tasks/task-73 - MPV-plugin-split-into-modules-and-optimize-startup-command-runtime.md index 53477eb..afd9e43 100644 --- a/backlog/tasks/task-73 - MPV-plugin-split-into-modules-and-optimize-startup-command-runtime.md +++ b/backlog/tasks/task-73 - MPV-plugin-split-into-modules-and-optimize-startup-command-runtime.md @@ -10,6 +10,8 @@ references: - plugin/subminer/main.lua - plugin/subminer/bootstrap.lua - plugin/subminer/process.lua + - plugin/subminer/aniskip.lua + - plugin/subminer/environment.lua - plugin/subminer/lifecycle.lua - plugin/subminer/messages.lua - plugin/subminer/ui.lua @@ -40,6 +42,8 @@ Scope: Replace monolithic `plugin/subminer.lua` with modular plugin runtime; opt Delivered behavior: - 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. +- 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. - 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. @@ -48,25 +52,24 @@ Delivered behavior: Risk/impact context: - 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. ## Final Summary -Startup fixed-sleep removal delivered in launcher + plugin start paths: -- `launcher/mpv.ts:startOverlay` no longer blocks on hard `2000ms`; now uses bounded readiness/event checks: - - bounded wait for mpv IPC socket readiness - - bounded wait for spawned overlay command settle (`exit`/`error`) -- `plugin/subminer/process.lua` removed fixed `0.35s` texthooker delay and fixed `0.6s` startup visibility delay. -- Overlay start now uses short bounded retries on non-ready start failures, then applies startup overlay visibility once start succeeds. +AniSkip gate/async update delivered in plugin runtime: +- `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger. +- `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches. +- `plugin/subminer/environment.lua`: async app-running detection with short cache. +- `plugin/subminer/messages.lua`: explicit script-message trigger wiring. -Regression coverage added: -- `launcher/mpv.test.ts` adds guard that `startOverlay` resolves quickly when readiness arrives. -- `scripts/test-plugin-process-start-retries.lua` asserts retry behavior and guards against reintroducing fixed `0.35/0.6` timers. +Regression coverage updated: +- `scripts/test-plugin-start-gate.lua` now verifies: + - 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: -- `bun test launcher/mpv.test.ts` 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). +- `bun run test:plugin:src` pass. diff --git a/docs/mpv-plugin.md b/docs/mpv-plugin.md index 9a489e0..83f6e47 100644 --- a/docs/mpv-plugin.md +++ b/docs/mpv-plugin.md @@ -1,6 +1,6 @@ # 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 @@ -10,7 +10,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse tar -xzf /tmp/subminer-assets.tar.gz -C /tmp mkdir -p ~/.config/SubMiner 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/ # 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 -- 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. - 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. @@ -201,7 +208,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no ## 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. - **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. diff --git a/plugin/subminer/aniskip.lua b/plugin/subminer/aniskip.lua new file mode 100644 index 0000000..36e59ac --- /dev/null +++ b/plugin/subminer/aniskip.lua @@ -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 diff --git a/plugin/subminer/environment.lua b/plugin/subminer/environment.lua new file mode 100644 index 0000000..3aa6f79 --- /dev/null +++ b/plugin/subminer/environment.lua @@ -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 diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua new file mode 100644 index 0000000..d3a99b3 --- /dev/null +++ b/plugin/subminer/lifecycle.lua @@ -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 diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua new file mode 100644 index 0000000..e0e4ff2 --- /dev/null +++ b/plugin/subminer/messages.lua @@ -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 diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 8d67a38..1964b82 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -3,7 +3,9 @@ local function run_plugin_scenario(config) local recorded = { async_calls = {}, + sync_calls = {}, script_messages = {}, + events = {}, osd = {}, logs = {}, } @@ -34,7 +36,12 @@ local function run_plugin_scenario(config) return config.chapter_list or {} end + function mp.get_script_directory() + return "plugin/subminer" + end + function mp.command_native(command) + recorded.sync_calls[#recorded.sync_calls + 1] = command local args = command.args or {} if args[1] == "ps" then return { @@ -44,6 +51,13 @@ local function run_plugin_scenario(config) } end if args[1] == "curl" then + local url = args[#args] or "" + if type(url) == "string" and url:find("myanimelist", 1, true) then + return { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" } + end + if type(url) == "string" and url:find("api.aniskip.com", 1, true) then + return { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" } + end return { status = 0, stdout = "{}", stderr = "" } end return { status = 0, stdout = "", stderr = "" } @@ -52,6 +66,22 @@ local function run_plugin_scenario(config) function mp.command_native_async(command, callback) recorded.async_calls[#recorded.async_calls + 1] = command 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) end end @@ -67,12 +97,18 @@ local function run_plugin_scenario(config) 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.observe_property(_name, _kind, _fn) end function mp.osd_message(message, _duration) recorded.osd[#recorded.osd + 1] = message end + function mp.set_osd_ass(...) end function mp.get_time() return 0 end @@ -90,8 +126,8 @@ local function run_plugin_scenario(config) local utils = {} function options.read_options(target, _name) - if config.socket_path then - target.socket_path = config.socket_path + for key, value in pairs(config.option_overrides or {}) do + target[key] = value end end @@ -108,7 +144,35 @@ local function run_plugin_scenario(config) return table.concat(parts, "/") 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 end @@ -149,7 +213,7 @@ local function run_plugin_scenario(config) return utils end - local ok, err = pcall(dofile, "plugin/subminer.lua") + local ok, err = pcall(dofile, "plugin/subminer/main.lua") if not ok then return nil, err, recorded end @@ -168,6 +232,39 @@ local function find_start_call(async_calls) local args = call.args or {} for i = 1, #args do 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 end end @@ -175,11 +272,22 @@ local function find_start_call(async_calls) return false 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" do local recorded, err = run_plugin_scenario({ process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + }, files = { [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.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered") 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 print("plugin start gate regression tests: OK")