diff --git a/.gitignore b/.gitignore index d5cf79e..f379803 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ release/ # Launcher build artifact (produced by make build-launcher) subminer +!plugin/subminer/ +!plugin/subminer/*.lua # Logs *.log diff --git a/Makefile b/Makefile index 6425784..432b010 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,6 @@ APP_NAME := subminer THEME_SOURCE := assets/themes/subminer.rasi LAUNCHER_OUT := dist/launcher/$(APP_NAME) THEME_FILE := subminer.rasi -PLUGIN_LUA := plugin/subminer.lua PLUGIN_CONF := plugin/subminer.conf # Default install prefix for the wrapper script. @@ -218,10 +217,11 @@ install-macos: build-launcher install-plugin: @printf '%s\n' "[INFO] Installing mpv plugin artifacts" @install -d "$(MPV_SCRIPTS_DIR)" + @install -d "$(MPV_SCRIPTS_DIR)/subminer" @install -d "$(MPV_SCRIPT_OPTS_DIR)" - @install -m 0644 "./$(PLUGIN_LUA)" "$(MPV_SCRIPTS_DIR)/subminer.lua" + @cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/" @install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf" - @printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer.lua" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf" + @printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf" # Uninstall behavior kept unchanged by default. uninstall: uninstall-linux diff --git a/README.md b/README.md index 82ba7af..6ebbe4b 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ chmod +x ~/.local/bin/subminer ```bash wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz tar -xzf /tmp/subminer-assets.tar.gz -C /tmp -cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/ +mkdir -p ~/.config/mpv/scripts/subminer +cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/ cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/ mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc ``` diff --git a/backlog/tasks/task-69 - Refactor-mpv-plugin-into-modular-script-components.md b/backlog/tasks/task-69 - Refactor-mpv-plugin-into-modular-script-components.md new file mode 100644 index 0000000..76ab23e --- /dev/null +++ b/backlog/tasks/task-69 - Refactor-mpv-plugin-into-modular-script-components.md @@ -0,0 +1,24 @@ +--- +id: TASK-69 +title: Refactor mpv plugin into modular script components +status: To Do +assignee: [] +created_date: '2026-02-24 17:09' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Break plugin/subminer.lua into smaller Lua modules under mpv scripts subdirectory while preserving user-visible behavior and keybindings. Include migration + docs updates for install paths and smoke/regression checks. + + +## Acceptance Criteria + +- [ ] #1 Plugin entrypoint stays at scripts/subminer.lua and loads modules from scripts/subminer/ +- [ ] #2 No behavior change for keybindings, script messages, auto-start, AniSkip, hover highlight +- [ ] #3 Install/docs updated for recursive plugin copy +- [ ] #4 Add or update regression checks for start/stop + module loading + diff --git a/docs/installation.md b/docs/installation.md index 18728c4..ff2323d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -150,7 +150,8 @@ 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 +cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/ cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/ # Option 2: from source checkout diff --git a/docs/mpv-plugin.md b/docs/mpv-plugin.md index 72be4cb..d92347d 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,8 @@ 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 +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 diff --git a/plugin/subminer.lua b/plugin/subminer.lua deleted file mode 100644 index 84d81f1..0000000 --- a/plugin/subminer.lua +++ /dev/null @@ -1,1951 +0,0 @@ -local input = require("mp.input") -local mp = require("mp") -local msg = require("mp.msg") -local options = require("mp.options") -local utils = require("mp.utils") - -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 is_subminer_process_running() - local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" } - local result = mp.command_native({ - name = "subprocess", - args = command, - playback_only = false, - capture_stdout = true, - capture_stderr = false, - }) - if not result or type(result.stdout) ~= "string" or result.status ~= 0 then - return false - end - - local process_list = result.stdout: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 is_subminer_app_running() - if is_subminer_process_running() then - return true - end - return false -end - -local function normalize_binary_path_candidate(candidate) - if type(candidate) ~= "string" then - return nil - end - local trimmed = candidate:match("^%s*(.-)%s*$") or "" - if trimmed == "" then - return nil - end - if #trimmed >= 2 then - local first = trimmed:sub(1, 1) - local last = trimmed:sub(-1) - if (first == '"' and last == '"') or (first == "'" and last == "'") then - trimmed = trimmed:sub(2, -2) - end - end - return trimmed ~= "" and trimmed or nil -end - -local function binary_candidates_from_app_path(app_path) - return { - utils.join_path(app_path, "Contents", "MacOS", "SubMiner"), - utils.join_path(app_path, "Contents", "MacOS", "subminer"), - } -end - -local opts = { - binary_path = "", - socket_path = default_socket_path(), - texthooker_enabled = true, - texthooker_port = 5174, - backend = "auto", - auto_start = true, - auto_start_overlay = false, -- legacy alias, maps to auto_start_visible_overlay - auto_start_visible_overlay = false, - auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden - osd_messages = true, - log_level = "info", - aniskip_enabled = true, - aniskip_title = "", - aniskip_season = "", - aniskip_mal_id = "", - aniskip_episode = "", - aniskip_show_button = true, - aniskip_button_text = "You can skip by pressing %s", - aniskip_button_key = "y-k", - aniskip_button_duration = 3, -} - -options.read_options(opts, "subminer") - -local state = { - overlay_running = false, - texthooker_running = false, - overlay_process = nil, - binary_available = false, - binary_path = nil, - detected_backend = nil, - invisible_overlay_visible = false, - hover_highlight = { - revision = -1, - payload = nil, - saved_sub_visibility = nil, - saved_secondary_sub_visibility = nil, - overlay_active = false, - cached_ass = nil, - clear_timer = nil, - last_hover_update_ts = 0, - }, - aniskip = { - mal_id = nil, - title = nil, - episode = nil, - intro_start = nil, - intro_end = nil, - found = false, - prompt_shown = false, - }, -} - -local HOVER_MESSAGE_NAME = "subminer-hover-token" -local HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token" -local DEFAULT_HOVER_BASE_COLOR = "FFFFFF" -local DEFAULT_HOVER_COLOR = "C6A0F6" - -local LOG_LEVEL_PRIORITY = { - debug = 10, - info = 20, - warn = 30, - error = 40, -} - -local function normalize_log_level(level) - local normalized = (level or "info"):lower() - if LOG_LEVEL_PRIORITY[normalized] then - return normalized - end - return "info" -end - -local function should_log(level) - local current = normalize_log_level(opts.log_level) - local target = normalize_log_level(level) - return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current] -end - -local function subminer_log(level, scope, message) - if not should_log(level) then - return - end - local timestamp = os.date("%Y-%m-%d %H:%M:%S") - local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message) - if level == "error" then - msg.error(line) - elseif level == "warn" then - msg.warn(line) - elseif level == "debug" then - msg.debug(line) - else - msg.info(line) - end -end - -local function show_osd(message) - if opts.osd_messages then - mp.osd_message("SubMiner: " .. message, 3) - end -end - -local function url_encode(text) - if type(text) ~= "string" then - return "" - end - local encoded = text:gsub("\n", " ") - encoded = encoded:gsub("([^%w%-_%.~ ])", function(char) - return string.format("%%%02X", string.byte(char)) - end) - return encoded:gsub(" ", "%%20") -end - -local function run_json_curl(url) - local result = mp.command_native({ - name = "subprocess", - args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url }, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then - return nil, result and result.stderr or "curl failed" - end - local parsed, parse_error = utils.parse_json(result.stdout) - if type(parsed) ~= "table" then - return nil, parse_error or "invalid json" - end - return parsed, nil -end - -local function parse_episode_hint(text) - if type(text) ~= "string" or text == "" then - return nil - end - local patterns = { - "[Ss]%d+[Ee](%d+)", - "[Ee][Pp]?[%s%._%-]*(%d+)", - "[%s%._%-]+(%d+)[%s%._%-]+", - } - for _, pattern in ipairs(patterns) do - local token = text:match(pattern) - if token then - local episode = tonumber(token) - if episode and episode > 0 and episode < 10000 then - return episode - end - end - end - return nil -end - -local function cleanup_title(raw) - if type(raw) ~= "string" then - return nil - end - local cleaned = raw - cleaned = cleaned:gsub("%b[]", " ") - cleaned = cleaned:gsub("%b()", " ") - cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ") - cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ") - cleaned = cleaned:gsub("[%._%-]+", " ") - cleaned = cleaned:gsub("%s+", " ") - cleaned = cleaned:match("^%s*(.-)%s*$") or "" - if cleaned == "" then - return nil - end - return cleaned -end - -local function extract_show_title_from_path(media_path) - if type(media_path) ~= "string" or media_path == "" then - return nil - end - local normalized = media_path:gsub("\\", "/") - local segments = {} - for segment in normalized:gmatch("[^/]+") do - segments[#segments + 1] = segment - end - for index = 1, #segments do - local segment = segments[index] or "" - if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then - local prior = segments[index - 1] - local cleaned = cleanup_title(prior or "") - if cleaned and cleaned ~= "" then - return cleaned - end - end - end - return nil -end - -local function normalize_for_match(value) - if type(value) ~= "string" then - return "" - end - return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or "" -end - -local MATCH_STOPWORDS = { - the = true, - this = true, - that = true, - world = true, - animated = true, - series = true, - season = true, - no = true, - on = true, - ["and"] = true, -} - -local function tokenize_match_words(value) - local normalized = normalize_for_match(value) - local tokens = {} - for token in normalized:gmatch("%S+") do - if #token >= 3 and not MATCH_STOPWORDS[token] then - tokens[#tokens + 1] = token - end - end - return tokens -end - -local function token_set(tokens) - local set = {} - for _, token in ipairs(tokens) do - set[token] = true - end - return set -end - -local function title_overlap_score(expected_title, candidate_title) - local expected = normalize_for_match(expected_title) - local candidate = normalize_for_match(candidate_title) - if expected == "" or candidate == "" then - return 0 - end - if candidate:find(expected, 1, true) then - return 120 - end - local expected_tokens = tokenize_match_words(expected_title) - local candidate_tokens = token_set(tokenize_match_words(candidate_title)) - if #expected_tokens == 0 then - return 0 - end - local score = 0 - local matched = 0 - for _, token in ipairs(expected_tokens) do - if candidate_tokens[token] then - score = score + 30 - matched = matched + 1 - else - score = score - 20 - end - end - if matched == 0 then - score = score - 80 - end - local coverage = matched / #expected_tokens - if #expected_tokens >= 2 then - -- Require strong multi-token agreement to avoid false positives like "Shadow Skill". - if coverage >= 0.8 then - score = score + 30 - elseif coverage >= 0.6 then - score = score + 10 - else - score = score - 50 - end - else - if coverage >= 1 then - score = score + 10 - end - end - return score -end - -local function has_any_sequel_marker(candidate_title) - local normalized = normalize_for_match(candidate_title) - if normalized == "" then - return false - end - local markers = { - "season 2", - "season 3", - "season 4", - "2nd season", - "3rd season", - "4th season", - "second season", - "third season", - "fourth season", - " ii ", - " iii ", - " iv ", - } - local padded = " " .. normalized .. " " - for _, marker in ipairs(markers) do - if padded:find(marker, 1, true) then - return true - end - end - return false -end - -local function season_signal_score(requested_season, candidate_title) - local season = tonumber(requested_season) - if not season or season < 1 then - return 0 - end - local normalized = " " .. normalize_for_match(candidate_title) .. " " - if normalized == " " then - return 0 - end - - if season == 1 then - return has_any_sequel_marker(candidate_title) and -60 or 20 - end - - local numeric_marker = string.format(" season %d ", season) - local ordinal_marker = string.format(" %dth season ", season) - local roman_markers = { - [2] = { " ii ", " second season ", " 2nd season " }, - [3] = { " iii ", " third season ", " 3rd season " }, - [4] = { " iv ", " fourth season ", " 4th season " }, - [5] = { " v ", " fifth season ", " 5th season " }, - } - - if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then - return 40 - end - local aliases = roman_markers[season] or {} - for _, marker in ipairs(aliases) do - if normalized:find(marker, 1, true) then - return 40 - end - end - if has_any_sequel_marker(candidate_title) then - return -20 - end - return 5 -end - -local function resolve_title_and_episode() - local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or "" - local forced_season = tonumber(opts.aniskip_season) - local forced_episode = tonumber(opts.aniskip_episode) - local media_title = mp.get_property("media-title") - local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or "" - local path = mp.get_property("path") or "" - local path_show_title = extract_show_title_from_path(path) - local candidate_title = nil - if path_show_title and path_show_title ~= "" then - candidate_title = path_show_title - elseif forced_title ~= "" then - candidate_title = forced_title - else - candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path) - end - local episode = forced_episode - or parse_episode_hint(media_title) - or parse_episode_hint(filename) - or parse_episode_hint(path) - or 1 - return candidate_title, episode, forced_season -end - -local function resolve_mal_id(title, season) - local forced_mal_id = tonumber(opts.aniskip_mal_id) - if forced_mal_id and forced_mal_id > 0 then - return forced_mal_id, "(forced-mal-id)" - end - if type(title) == "string" and title:match("^%d+$") then - local numeric = tonumber(title) - if numeric and numeric > 0 then - return numeric, title - end - end - if type(title) ~= "string" or title == "" then - return nil, nil - end - - local lookup = title - if season and season > 1 then - lookup = string.format("%s Season %d", lookup, season) - end - local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup) - local mal_json, mal_error = run_json_curl(mal_url) - if not mal_json then - subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error)) - return nil, lookup - end - local categories = mal_json.categories - if type(categories) ~= "table" then - return nil, lookup - end - for _, category in ipairs(categories) do - if type(category) == "table" and type(category.items) == "table" then - for _, item in ipairs(category.items) do - if type(item) == "table" and tonumber(item.id) then - subminer_log( - "info", - "aniskip", - string.format( - 'MAL candidate selected (first result): id=%s name="%s" season_hint=%s', - tostring(item.id), - tostring(item.name or ""), - tostring(season or "-") - ) - ) - return tonumber(item.id), lookup - end - end - end - end - return nil, lookup -end - -local function set_intro_chapters(intro_start, intro_end) - if type(intro_start) ~= "number" or type(intro_end) ~= "number" then - return - end - local current = mp.get_property_native("chapter-list") - local chapters = {} - if type(current) == "table" then - for _, chapter in ipairs(current) do - local title = type(chapter) == "table" and chapter.title or nil - if type(title) ~= "string" or not title:match("^AniSkip ") then - chapters[#chapters + 1] = chapter - end - end - end - chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" } - chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" } - table.sort(chapters, function(a, b) - local a_time = type(a) == "table" and tonumber(a.time) or 0 - local b_time = type(b) == "table" and tonumber(b.time) or 0 - return a_time < b_time - end) - mp.set_property_native("chapter-list", chapters) -end - -local function remove_aniskip_chapters() - local current = mp.get_property_native("chapter-list") - if type(current) ~= "table" then - return - end - local chapters = {} - local changed = false - for _, chapter in ipairs(current) do - local title = type(chapter) == "table" and chapter.title or nil - if type(title) == "string" and title:match("^AniSkip ") then - changed = true - else - chapters[#chapters + 1] = chapter - end - end - if changed then - mp.set_property_native("chapter-list", chapters) - end -end - -local function clear_aniskip_state() - state.aniskip.prompt_shown = false - state.aniskip.found = false - state.aniskip.mal_id = nil - state.aniskip.title = nil - state.aniskip.episode = nil - state.aniskip.intro_start = nil - state.aniskip.intro_end = nil - remove_aniskip_chapters() -end - -local function skip_intro_now() - if not state.aniskip.found then - show_osd("Intro skip unavailable") - return - end - local intro_start = state.aniskip.intro_start - local intro_end = state.aniskip.intro_end - if type(intro_start) ~= "number" or type(intro_end) ~= "number" then - show_osd("Intro markers missing") - return - end - local now = mp.get_property_number("time-pos") - if type(now) ~= "number" then - show_osd("Skip unavailable") - return - end - local epsilon = 0.35 - if now < (intro_start - epsilon) or now > (intro_end + epsilon) then - show_osd("Skip intro only during intro") - return - end - mp.set_property_number("time-pos", intro_end) - show_osd("Skipped intro") -end - -local function update_intro_button_visibility() - if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then - return - end - local now = mp.get_property_number("time-pos") - if type(now) ~= "number" then - return - end - local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1) - local intro_start = state.aniskip.intro_start or -1 - local hint_window_end = intro_start + 3 - if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then - local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k" - local message = string.format(opts.aniskip_button_text, key) - mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3) - state.aniskip.prompt_shown = true - end -end - -local function apply_aniskip_payload(mal_id, title, episode, payload) - local results = payload and payload.results - if type(results) ~= "table" then - return false - end - for _, item in ipairs(results) do - if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then - local intro_start = tonumber(item.interval.start_time) - local intro_end = tonumber(item.interval.end_time) - if intro_start and intro_end and intro_end > intro_start then - state.aniskip.found = true - state.aniskip.mal_id = mal_id - state.aniskip.title = title - state.aniskip.episode = episode - state.aniskip.intro_start = intro_start - state.aniskip.intro_end = intro_end - state.aniskip.prompt_shown = false - set_intro_chapters(intro_start, intro_end) - subminer_log( - "info", - "aniskip", - string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode) - ) - return true - end - end - end - return false -end - -local function fetch_aniskip_for_current_media() - if not is_subminer_app_running() then - subminer_log("debug", "lifecycle", "Skipping aniskip lookup: SubMiner app not running") - return - end - - clear_aniskip_state() - if not opts.aniskip_enabled then - return - end - local title, episode, season = resolve_title_and_episode() - local media_title_fallback = cleanup_title(mp.get_property("media-title")) - local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "") - local path_fallback = cleanup_title(mp.get_property("path") or "") - local lookup_titles = {} - local seen_titles = {} - local function push_lookup_title(candidate) - if type(candidate) ~= "string" then - return - end - local trimmed = candidate:match("^%s*(.-)%s*$") or "" - if trimmed == "" then - return - end - local key = trimmed:lower() - if seen_titles[key] then - return - end - seen_titles[key] = true - lookup_titles[#lookup_titles + 1] = trimmed - end - push_lookup_title(title) - push_lookup_title(media_title_fallback) - push_lookup_title(filename_fallback) - push_lookup_title(path_fallback) - - subminer_log( - "info", - "aniskip", - string.format( - 'Query context: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)', - tostring(title or ""), - tostring(season or "-"), - tostring(episode or "-"), - tostring(opts.aniskip_title or ""), - tostring(opts.aniskip_season or "-"), - tostring(opts.aniskip_episode or "-"), - tostring(opts.aniskip_mal_id or "-"), - #lookup_titles - ) - ) - local mal_id, mal_lookup = nil, nil - for index, lookup_title in ipairs(lookup_titles) do - subminer_log( - "info", - "aniskip", - string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title) - ) - local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season) - if attempt_mal_id then - mal_id = attempt_mal_id - mal_lookup = attempt_lookup - break - end - mal_lookup = attempt_lookup or mal_lookup - end - if not mal_id then - subminer_log( - "info", - "aniskip", - string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")) - ) - return - end - local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode) - subminer_log( - "info", - "aniskip", - string.format('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url) - ) - local payload, fetch_error = run_json_curl(url) - if not payload then - subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error)) - return - end - if payload.found ~= true then - subminer_log("info", "aniskip", "AniSkip: no skip windows found") - return - end - if not apply_aniskip_payload(mal_id, title, episode, payload) then - subminer_log("info", "aniskip", "AniSkip payload did not include OP interval") - end -end - -local function to_hex_color(input) - if type(input) ~= "string" then - return nil - end - - local hex = input:gsub("[%#%']", ""):gsub("^0x", "") - if #hex ~= 6 and #hex ~= 3 then - return nil - end - if #hex == 3 then - return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3) - end - return hex -end - -local function fix_ass_color(input, fallback) - local hex = to_hex_color(input) - if not hex then - return fallback or DEFAULT_HOVER_BASE_COLOR - end - local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6) - return b .. g .. r -end - -local function escape_ass_text(text) - return (text or ""):gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}"):gsub("\n", "\\N") -end - -local function resolve_osd_dimensions() - local width = mp.get_property_number("osd-width", 0) or 0 - local height = mp.get_property_number("osd-height", 0) or 0 - - if width <= 0 or height <= 0 then - local osd_dims = mp.get_property_native("osd-dimensions") - if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then - width = osd_dims.w - end - if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then - height = osd_dims.h - end - end - - if width <= 0 then - width = 1280 - end - if height <= 0 then - height = 720 - end - - return width, height -end - -local function resolve_metrics() - local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36 - local sub_scale = mp.get_property_number("sub-scale", 1) or 1 - local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true - local sub_pos = mp.get_property_number("sub-pos", 100) or 100 - local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0 - local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif" - local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0 - local sub_bold = mp.get_property_bool("sub-bold", false) == true - local sub_italic = mp.get_property_bool("sub-italic", false) == true - local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2 - local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0 - local osd_w, osd_h = resolve_osd_dimensions() - local window_scale = 1 - if sub_scale_by_window and osd_h > 0 then - window_scale = osd_h / 720 - end - local effective_margin_y = sub_margin_y * window_scale - - return { - font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale, - pos = sub_pos, - margin_y = effective_margin_y, - font = sub_font, - spacing = sub_spacing, - bold = sub_bold, - italic = sub_italic, - border = sub_border_size * window_scale, - shadow = sub_shadow_offset * window_scale, - base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR), - hover_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_COLOR), - } -end - -local function get_subtitle_ass_property() - local ass_text = mp.get_property("sub-text/ass") - if type(ass_text) == "string" and ass_text ~= "" then - return ass_text - end - - ass_text = mp.get_property("sub-text-ass") - if type(ass_text) == "string" and ass_text ~= "" then - return ass_text - end - - return nil -end - -local function plain_text_and_ass_map(text) - local plain = {} - local map = {} - local plain_len = 0 - local i = 1 - local text_len = #text - - while i <= text_len do - local ch = text:sub(i, i) - if ch == "{" then - local close = text:find("}", i + 1, true) - if not close then - break - end - i = close + 1 - elseif ch == "\\" then - local esc = text:sub(i + 1, i + 1) - if esc == "N" or esc == "n" then - plain_len = plain_len + 1 - plain[plain_len] = "\n" - map[plain_len] = i - i = i + 2 - elseif esc == "h" then - plain_len = plain_len + 1 - plain[plain_len] = " " - map[plain_len] = i - i = i + 2 - elseif esc == "{" then - plain_len = plain_len + 1 - plain[plain_len] = "{" - map[plain_len] = i - i = i + 2 - elseif esc == "}" then - plain_len = plain_len + 1 - plain[plain_len] = "}" - map[plain_len] = i - i = i + 2 - elseif esc == "\\" then - plain_len = plain_len + 1 - plain[plain_len] = "\\" - map[plain_len] = i - i = i + 2 - else - local seq_end = i + 1 - while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do - seq_end = seq_end + 1 - end - if text:sub(seq_end, seq_end) == "(" then - local close = text:find(")", seq_end, true) - if close then - i = close + 1 - else - i = seq_end + 1 - end - else - i = seq_end + 1 - end - end - else - plain_len = plain_len + 1 - plain[plain_len] = ch - map[plain_len] = i - i = i + 1 - end - end - - return table.concat(plain), map -end - -local function find_hover_span(payload, plain) - local source_len = #plain - local cursor = 1 - for _, token in ipairs(payload.tokens or {}) do - if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then - goto continue - end - - local token_text = token.text - local start_pos = nil - local end_pos = nil - - if type(token.startPos) == "number" and type(token.endPos) == "number" then - if token.startPos >= 0 and token.endPos >= token.startPos then - local candidate_start = token.startPos + 1 - local candidate_stop = token.endPos - if - candidate_start >= 1 - and candidate_stop <= source_len - and candidate_stop >= candidate_start - and plain:sub(candidate_start, candidate_stop) == token_text - then - start_pos = candidate_start - end_pos = candidate_stop - end - end - end - - if not start_pos or not end_pos then - local fallback_start, fallback_stop = plain:find(token_text, cursor, true) - if not fallback_start then - fallback_start, fallback_stop = plain:find(token_text, 1, true) - end - start_pos, end_pos = fallback_start, fallback_stop - end - - if start_pos and end_pos then - if token.index == payload.hoveredTokenIndex then - return start_pos, end_pos - end - cursor = end_pos + 1 - end - - ::continue:: - end - - return nil -end - -local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color) - if hover_start == nil or hover_end == nil then - return raw_ass - end - - local raw_open_idx = plain_map[hover_start] or 1 - local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1) - if raw_open_idx < 1 then - raw_open_idx = 1 - end - if raw_close_idx < 1 then - raw_close_idx = 1 - end - if raw_open_idx > #raw_ass + 1 then - raw_open_idx = #raw_ass + 1 - end - if raw_close_idx > #raw_ass + 1 then - raw_close_idx = #raw_ass + 1 - end - - local open_tag = string.format("{\\1c&H%s&}", hover_color) - local close_tag = string.format("{\\1c&H%s&}", base_color) - local changes = { - { idx = raw_open_idx, tag = open_tag }, - { idx = raw_close_idx, tag = close_tag }, - } - table.sort(changes, function(a, b) - return a.idx < b.idx - end) - - local output = {} - local cursor = 1 - for _, change in ipairs(changes) do - if change.idx > #raw_ass + 1 then - change.idx = #raw_ass + 1 - end - if change.idx < 1 then - change.idx = 1 - end - if change.idx > cursor then - output[#output + 1] = raw_ass:sub(cursor, change.idx - 1) - end - output[#output + 1] = change.tag - cursor = change.idx - end - if cursor <= #raw_ass then - output[#output + 1] = raw_ass:sub(cursor) - end - - return table.concat(output) -end - -local function build_hover_subtitle_content(payload) - local source_ass = get_subtitle_ass_property() - if type(source_ass) == "string" and source_ass ~= "" then - state.hover_highlight.cached_ass = source_ass - else - source_ass = state.hover_highlight.cached_ass - end - if type(source_ass) ~= "string" or source_ass == "" then - return nil - end - - local plain_source, plain_map = plain_text_and_ass_map(source_ass) - if type(plain_source) ~= "string" or plain_source == "" then - return nil - end - - local hover_start, hover_end = find_hover_span(payload, plain_source) - if not hover_start or not hover_end then - return nil - end - - local metrics = resolve_metrics() - local hover_color = fix_ass_color(payload.colors and payload.colors.hover or nil, metrics.hover_color) - local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color) - return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color) -end - -local function clear_hover_overlay() - if state.hover_highlight.clear_timer then - state.hover_highlight.clear_timer:kill() - state.hover_highlight.clear_timer = nil - end - if state.hover_highlight.overlay_active then - if type(state.hover_highlight.saved_sub_visibility) == "string" then - mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility) - else - mp.set_property("sub-visibility", "yes") - end - if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then - mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility) - end - state.hover_highlight.saved_sub_visibility = nil - state.hover_highlight.saved_secondary_sub_visibility = nil - state.hover_highlight.overlay_active = false - end - mp.set_osd_ass(0, 0, "") - state.hover_highlight.payload = nil - state.hover_highlight.revision = -1 - state.hover_highlight.cached_ass = nil - state.hover_highlight.last_hover_update_ts = 0 -end - -local function schedule_hover_clear(delay_seconds) - if state.hover_highlight.clear_timer then - state.hover_highlight.clear_timer:kill() - state.hover_highlight.clear_timer = nil - end - state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function() - state.hover_highlight.clear_timer = nil - clear_hover_overlay() - end) -end - -local function render_hover_overlay(payload) - if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then - clear_hover_overlay() - return - end - - local ass = build_hover_subtitle_content(payload) - if not ass then - -- Transient parse/mapping miss; keep previous frame to avoid flicker. - return - end - - local osd_w, osd_h = resolve_osd_dimensions() - local metrics = resolve_metrics() - local osd_dims = mp.get_property_native("osd-dimensions") - local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0 - local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0 - local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0 - local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0 - local usable_w = math.max(1, osd_w - ml - mr) - local usable_h = math.max(1, osd_h - mt - mb) - local anchor_x = math.floor(ml + usable_w / 2) - local baseline_adjust = (metrics.border + metrics.shadow) * 5 - local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust) - local font_size = math.max(8, metrics.font_size) - local anchor_tag = string.format( - "{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}", - anchor_x, - anchor_y, - escape_ass_text(metrics.font), - font_size, - metrics.bold and 1 or 0, - metrics.italic and 1 or 0, - metrics.spacing, - metrics.border, - metrics.shadow, - metrics.base_color - ) - if not state.hover_highlight.overlay_active then - state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility") - state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility") - mp.set_property("sub-visibility", "no") - mp.set_property("secondary-sub-visibility", "no") - state.hover_highlight.overlay_active = true - end - mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass) -end - -local function handle_hover_message(payload_json) - local parsed, parse_error = utils.parse_json(payload_json) - if not parsed then - msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error)) - clear_hover_overlay() - return - end - - if type(parsed.revision) ~= "number" then - clear_hover_overlay() - return - end - - if parsed.revision < state.hover_highlight.revision then - return - end - - if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then - if state.hover_highlight.clear_timer then - state.hover_highlight.clear_timer:kill() - state.hover_highlight.clear_timer = nil - end - state.hover_highlight.revision = parsed.revision - state.hover_highlight.payload = parsed - state.hover_highlight.last_hover_update_ts = mp.get_time() or 0 - render_hover_overlay(state.hover_highlight.payload) - return - end - - local now = mp.get_time() or 0 - local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0) - state.hover_highlight.revision = parsed.revision - state.hover_highlight.payload = nil - if state.hover_highlight.overlay_active then - if elapsed_since_hover > 0.35 then - -- Ignore stale null-hover updates while pointer is stationary. - return - end - schedule_hover_clear(0.08) - else - clear_hover_overlay() - end -end - -local function detect_backend() - if state.detected_backend then - return state.detected_backend - end - - local backend = nil - - 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 - - state.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 - -local function file_exists(path) - local info = utils.file_info(path) - if not info then - return false - end - if info.is_dir ~= nil then - return not info.is_dir - end - return true -end - -local function resolve_binary_candidate(candidate) - local normalized = normalize_binary_path_candidate(candidate) - if not normalized then - return nil - end - - if file_exists(normalized) then - return normalized - end - - if not normalized:lower():find("%.app") then - return nil - end - - local app_root = normalized - if not app_root:lower():match("%.app$") then - app_root = normalized:match("(.+%.app)") - end - if not app_root then - return nil - end - - for _, path in ipairs(binary_candidates_from_app_path(app_root)) do - if file_exists(path) then - return path - end - end - - return nil -end - -local function find_binary_override() - local candidates = { - resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")), - resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")), - } - - for _, path in ipairs(candidates) do - if path and path ~= "" then - return path - end - end - - return nil -end - -local function find_binary() - local override = find_binary_override() - if override then - return override - end - - local configured = resolve_binary_candidate(opts.binary_path) - if configured then - return configured - end - - local search_paths = { - "/Applications/SubMiner.app/Contents/MacOS/SubMiner", - utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"), - "C:\\Program Files\\SubMiner\\SubMiner.exe", - "C:\\Program Files (x86)\\SubMiner\\SubMiner.exe", - "C:\\SubMiner\\SubMiner.exe", - utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"), - "/opt/SubMiner/SubMiner.AppImage", - "/usr/local/bin/SubMiner", - "/usr/bin/SubMiner", - } - - for _, path in ipairs(search_paths) do - if file_exists(path) then - subminer_log("info", "binary", "Found binary at: " .. path) - return path - end - end - - return nil -end - -local function ensure_binary_available() - if state.binary_available and state.binary_path and file_exists(state.binary_path) then - return true - end - - local discovered = find_binary() - if discovered then - state.binary_path = discovered - state.binary_available = true - return true - end - - state.binary_path = nil - state.binary_available = false - return false -end - -local function resolve_backend(override_backend) - local selected = override_backend - if selected == nil or selected == "" then - selected = opts.backend - end - if selected == "auto" then - return detect_backend() - end - return selected -end - -local function build_command_args(action, overrides) - overrides = overrides or {} - local args = { state.binary_path } - - table.insert(args, "--" .. action) - local log_level = normalize_log_level(overrides.log_level or opts.log_level) - if log_level ~= "info" then - table.insert(args, "--log-level") - table.insert(args, log_level) - end - - local needs_start_context = action == "start" - - if needs_start_context then - local backend = resolve_backend(overrides.backend) - if backend and backend ~= "" then - table.insert(args, "--backend") - table.insert(args, backend) - end - - local socket_path = overrides.socket_path or opts.socket_path - table.insert(args, "--socket") - table.insert(args, socket_path) - end - - return args -end - -local function run_control_command(action) - local args = build_command_args(action) - subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) - local result = mp.command_native({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - return result and result.status == 0 -end - -local function coerce_bool(value, fallback) - if type(value) == "boolean" then - return value - end - if type(value) == "string" then - local normalized = value:lower() - if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then - return true - end - if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then - return false - end - end - return fallback -end - -local function parse_start_script_message_overrides(...) - local overrides = {} - for i = 1, select("#", ...) do - local token = select(i, ...) - if type(token) == "string" and token ~= "" then - local key, value = token:match("^([%w_%-]+)=(.+)$") - if key and value then - local normalized_key = key:lower() - if normalized_key == "backend" then - local backend = value:lower() - if - backend == "auto" - or backend == "hyprland" - or backend == "sway" - or backend == "x11" - or backend == "macos" - then - overrides.backend = backend - end - elseif normalized_key == "socket" or normalized_key == "socket_path" then - overrides.socket_path = value - elseif normalized_key == "texthooker" or normalized_key == "texthooker_enabled" then - local parsed = coerce_bool(value, nil) - if parsed ~= nil then - overrides.texthooker_enabled = parsed - end - elseif normalized_key == "log-level" or normalized_key == "log_level" then - overrides.log_level = normalize_log_level(value) - end - end - end - end - return overrides -end - -local function resolve_visible_overlay_startup() - local visible = coerce_bool(opts.auto_start_visible_overlay, false) - -- Backward compatibility for old config key. - if coerce_bool(opts.auto_start_overlay, false) then - visible = true - end - return visible -end - -local function resolve_invisible_overlay_startup() - local raw = opts.auto_start_invisible_overlay - if type(raw) == "boolean" then - return raw - end - - local mode = type(raw) == "string" and raw:lower() or "platform-default" - if mode == "visible" or mode == "show" or mode == "yes" or mode == "true" or mode == "on" then - return true - end - if mode == "hidden" or mode == "hide" or mode == "no" or mode == "false" or mode == "off" then - return false - end - - -- platform-default - return not is_linux() -end - -local function apply_startup_overlay_preferences() - local should_show_visible = resolve_visible_overlay_startup() - local should_show_invisible = resolve_invisible_overlay_startup() - - local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay" - if not run_control_command(visible_action) then - subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action) - end - - local invisible_action = should_show_invisible and "show-invisible-overlay" or "hide-invisible-overlay" - if not run_control_command(invisible_action) then - subminer_log("warn", "process", "Failed to apply invisible startup action: " .. invisible_action) - end - - state.invisible_overlay_visible = should_show_invisible -end - -local function build_texthooker_args() - local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) } - local log_level = normalize_log_level(opts.log_level) - if log_level ~= "info" then - table.insert(args, "--log-level") - table.insert(args, log_level) - end - return args -end - -local function ensure_texthooker_running(callback) - if not opts.texthooker_enabled then - callback() - return - end - - if state.texthooker_running then - callback() - return - end - - local args = build_texthooker_args() - subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " ")) - state.texthooker_running = true - - mp.command_native_async({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }, function(success, result, error) - if not success or (result and result.status ~= 0) then - state.texthooker_running = false - subminer_log( - "warn", - "texthooker", - "Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error") - ) - end - end) - - -- Give the process a moment to acquire the app lock before sending --start. - mp.add_timeout(0.35, callback) -end - -local function start_overlay(overrides) - if not ensure_binary_available() then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - if state.overlay_running then - subminer_log("info", "process", "Overlay already running") - show_osd("Already running") - return - end - - overrides = overrides or {} - local texthooker_enabled = overrides.texthooker_enabled - if texthooker_enabled == nil then - texthooker_enabled = opts.texthooker_enabled - end - - local function launch_overlay() - local args = build_command_args("start", overrides) - subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " ")) - - show_osd("Starting...") - state.overlay_running = true - - mp.command_native_async({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }, function(success, result, error) - if not success or (result and result.status ~= 0) then - state.overlay_running = false - subminer_log( - "error", - "process", - "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") - ) - show_osd("Overlay start failed") - end - end) - - -- Apply explicit startup visibility for each overlay layer. - mp.add_timeout(0.6, function() - apply_startup_overlay_preferences() - end) - end - - if texthooker_enabled then - ensure_texthooker_running(launch_overlay) - else - launch_overlay() - end -end - -local function start_overlay_from_script_message(...) - local overrides = parse_start_script_message_overrides(...) - start_overlay(overrides) -end - -local function stop_overlay() - if not ensure_binary_available() then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - local args = build_command_args("stop") - subminer_log("info", "process", "Stopping overlay: " .. table.concat(args, " ")) - - local result = mp.command_native({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - - state.overlay_running = false - state.texthooker_running = false - if result.status == 0 then - subminer_log("info", "process", "Overlay stopped") - else - subminer_log("warn", "process", "Stop command returned non-zero status: " .. tostring(result.status)) - end - show_osd("Stopped") -end - -local function toggle_overlay() - if not ensure_binary_available() then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - local args = build_command_args("toggle") - subminer_log("info", "process", "Toggling overlay: " .. table.concat(args, " ")) - - local result = mp.command_native({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - - if result and result.status ~= 0 then - subminer_log("warn", "process", "Toggle command failed") - show_osd("Toggle failed") - end -end - -local function toggle_invisible_overlay() - if not ensure_binary_available() then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - local args = build_command_args("toggle-invisible-overlay") - subminer_log("info", "process", "Toggling invisible overlay: " .. table.concat(args, " ")) - - local result = mp.command_native({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - - if result and result.status ~= 0 then - subminer_log("warn", "process", "Invisible toggle command failed") - show_osd("Invisible toggle failed") - return - end - - state.invisible_overlay_visible = not state.invisible_overlay_visible - show_osd("Invisible overlay: " .. (state.invisible_overlay_visible and "visible" or "hidden")) -end - -local function show_invisible_overlay() - if not ensure_binary_available() then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - local args = build_command_args("show-invisible-overlay") - subminer_log("info", "process", "Showing invisible overlay: " .. table.concat(args, " ")) - - local result = mp.command_native({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - - if result and result.status ~= 0 then - subminer_log("warn", "process", "Show invisible command failed") - show_osd("Show invisible failed") - return - end - - state.invisible_overlay_visible = true - show_osd("Invisible overlay: visible") -end - -local function hide_invisible_overlay() - if not ensure_binary_available() then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - local args = build_command_args("hide-invisible-overlay") - subminer_log("info", "process", "Hiding invisible overlay: " .. table.concat(args, " ")) - - local result = mp.command_native({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - - if result and result.status ~= 0 then - subminer_log("warn", "process", "Hide invisible command failed") - show_osd("Hide invisible failed") - return - end - - state.invisible_overlay_visible = false - show_osd("Invisible overlay: hidden") -end - -local function open_options() - if not state.binary_available then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - local args = build_command_args("settings") - subminer_log("info", "process", "Opening options: " .. table.concat(args, " ")) - local result = mp.command_native({ - name = "subprocess", - args = args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - if result.status == 0 then - subminer_log("info", "process", "Options window opened") - show_osd("Options opened") - else - subminer_log("warn", "process", "Failed to open options") - show_osd("Failed to open options") - end -end - -local restart_overlay -local check_status - -local function show_menu() - if not state.binary_available then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - local items = { - "Start overlay", - "Stop overlay", - "Toggle overlay", - "Toggle invisible overlay", - "Open options", - "Restart overlay", - "Check status", - } - - local actions = { - start_overlay, - stop_overlay, - toggle_overlay, - toggle_invisible_overlay, - open_options, - restart_overlay, - check_status, - } - - input.select({ - prompt = "SubMiner: ", - items = items, - submit = function(index) - if index and actions[index] then - actions[index]() - end - end, - }) -end - -restart_overlay = function() - if not ensure_binary_available() then - subminer_log("error", "binary", "SubMiner binary not found") - show_osd("Error: binary not found") - return - end - - subminer_log("info", "process", "Restarting overlay...") - show_osd("Restarting...") - - local stop_args = build_command_args("stop") - mp.command_native({ - name = "subprocess", - args = stop_args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }) - - state.overlay_running = false - state.texthooker_running = false - - ensure_texthooker_running(function() - local start_args = build_command_args("start") - subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " ")) - - state.overlay_running = true - mp.command_native_async({ - name = "subprocess", - args = start_args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }, function(success, result, error) - if not success or (result and result.status ~= 0) then - state.overlay_running = false - subminer_log( - "error", - "process", - "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") - ) - show_osd("Restart failed") - else - show_osd("Restarted successfully") - end - end) - end) -end - -check_status = function() - if not state.binary_available then - show_osd("Status: binary not found") - return - end - - local status = state.overlay_running and "running" or "stopped" - show_osd("Status: overlay is " .. status) - subminer_log("info", "process", "Status check: overlay is " .. status) -end - -local function on_file_loaded() - if not is_subminer_app_running() then - clear_aniskip_state() - subminer_log("debug", "lifecycle", "Skipping file load hooks: SubMiner app not running") - return true - end - - clear_aniskip_state() - fetch_aniskip_for_current_media() - state.binary_path = find_binary() - if state.binary_path then - state.binary_available = true - subminer_log("info", "lifecycle", "SubMiner ready (binary: " .. state.binary_path .. ")") - local should_auto_start = coerce_bool(opts.auto_start, false) - if should_auto_start then - start_overlay() - end - else - state.binary_available = false - subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled") - if opts.binary_path ~= "" then - subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist") - end - end -end - -local function on_shutdown() - clear_aniskip_state() - clear_hover_overlay() - if (state.overlay_running or state.texthooker_running) and state.binary_available then - subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process") - show_osd("Shutting down...") - stop_overlay() - end -end - -local function register_keybindings() - mp.add_key_binding("y-s", "subminer-start", start_overlay) - mp.add_key_binding("y-S", "subminer-stop", stop_overlay) - mp.add_key_binding("y-t", "subminer-toggle", toggle_overlay) - mp.add_key_binding("y-i", "subminer-toggle-invisible", toggle_invisible_overlay) - mp.add_key_binding("y-I", "subminer-show-invisible", show_invisible_overlay) - mp.add_key_binding("y-u", "subminer-hide-invisible", hide_invisible_overlay) - mp.add_key_binding("y-y", "subminer-menu", show_menu) - mp.add_key_binding("y-o", "subminer-options", open_options) - mp.add_key_binding("y-r", "subminer-restart", restart_overlay) - mp.add_key_binding("y-c", "subminer-status", check_status) - if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then - mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", skip_intro_now) - end - if opts.aniskip_button_key ~= "y-k" then - mp.add_key_binding("y-k", "subminer-skip-intro-fallback", skip_intro_now) - end -end - -local function register_script_messages() - mp.register_script_message("subminer-start", start_overlay_from_script_message) - mp.register_script_message("subminer-stop", stop_overlay) - mp.register_script_message("subminer-toggle", toggle_overlay) - mp.register_script_message("subminer-toggle-invisible", toggle_invisible_overlay) - mp.register_script_message("subminer-show-invisible", show_invisible_overlay) - mp.register_script_message("subminer-hide-invisible", hide_invisible_overlay) - mp.register_script_message("subminer-menu", show_menu) - mp.register_script_message("subminer-options", open_options) - mp.register_script_message("subminer-restart", restart_overlay) - mp.register_script_message("subminer-status", check_status) - mp.register_script_message("subminer-aniskip-refresh", fetch_aniskip_for_current_media) - mp.register_script_message("subminer-skip-intro", skip_intro_now) - mp.register_script_message(HOVER_MESSAGE_NAME, function(payload_json) - handle_hover_message(payload_json) - end) - mp.register_script_message(HOVER_MESSAGE_NAME_LEGACY, function(payload_json) - handle_hover_message(payload_json) - end) -end - -local function init() - register_keybindings() - register_script_messages() - - mp.register_event("file-loaded", on_file_loaded) - mp.register_event("shutdown", on_shutdown) - mp.register_event("file-loaded", clear_hover_overlay) - mp.register_event("end-file", clear_hover_overlay) - mp.register_event("shutdown", clear_hover_overlay) - mp.register_event("end-file", clear_aniskip_state) - mp.register_event("shutdown", clear_aniskip_state) - mp.add_hook("on_unload", 10, function() - clear_hover_overlay() - clear_aniskip_state() - end) - mp.observe_property("sub-start", "native", function() - clear_hover_overlay() - end) - mp.observe_property("time-pos", "number", function() - update_intro_button_visibility() - end) - - subminer_log("info", "lifecycle", "SubMiner plugin loaded") -end - -init() diff --git a/plugin/subminer/aniskip.lua b/plugin/subminer/aniskip.lua new file mode 100644 index 0000000..a0dec59 --- /dev/null +++ b/plugin/subminer/aniskip.lua @@ -0,0 +1,412 @@ +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 function url_encode(text) + if type(text) ~= "string" then + return "" + end + local encoded = text:gsub("\n", " ") + encoded = encoded:gsub("([^%w%-_%.~ ])", function(char) + return string.format("%%%02X", string.byte(char)) + end) + return encoded:gsub(" ", "%%20") + end + + local function run_json_curl(url) + local result = mp.command_native({ + name = "subprocess", + args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url }, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then + return nil, result and result.stderr or "curl failed" + end + local parsed, parse_error = utils.parse_json(result.stdout) + if type(parsed) ~= "table" then + return nil, parse_error or "invalid json" + end + return parsed, nil + end + + local function parse_episode_hint(text) + if type(text) ~= "string" or text == "" then + return nil + end + local patterns = { + "[Ss]%d+[Ee](%d+)", + "[Ee][Pp]?[%s%._%-]*(%d+)", + "[%s%._%-]+(%d+)[%s%._%-]+", + } + for _, pattern in ipairs(patterns) do + local token = text:match(pattern) + if token then + local episode = tonumber(token) + if episode and episode > 0 and episode < 10000 then + return episode + end + end + end + return nil + end + + local function cleanup_title(raw) + if type(raw) ~= "string" then + return nil + end + local cleaned = raw + cleaned = cleaned:gsub("%b[]", " ") + cleaned = cleaned:gsub("%b()", " ") + cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ") + cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ") + cleaned = cleaned:gsub("[%._%-]+", " ") + cleaned = cleaned:gsub("%s+", " ") + cleaned = cleaned:match("^%s*(.-)%s*$") or "" + if cleaned == "" then + return nil + end + return cleaned + end + + local function extract_show_title_from_path(media_path) + if type(media_path) ~= "string" or media_path == "" then + return nil + end + local normalized = media_path:gsub("\\", "/") + local segments = {} + for segment in normalized:gmatch("[^/]+") do + segments[#segments + 1] = segment + end + for index = 1, #segments do + local segment = segments[index] or "" + if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then + local prior = segments[index - 1] + local cleaned = cleanup_title(prior or "") + if cleaned and cleaned ~= "" then + return cleaned + end + end + end + return nil + end + + local function resolve_title_and_episode() + local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or "" + local forced_season = tonumber(opts.aniskip_season) + local forced_episode = tonumber(opts.aniskip_episode) + local media_title = mp.get_property("media-title") + local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or "" + local path = mp.get_property("path") or "" + local path_show_title = extract_show_title_from_path(path) + local candidate_title = nil + if path_show_title and path_show_title ~= "" then + candidate_title = path_show_title + elseif forced_title ~= "" then + candidate_title = forced_title + else + candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path) + end + local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1 + return candidate_title, episode, forced_season + end + + local function 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(title, season) + local forced_mal_id = tonumber(opts.aniskip_mal_id) + if forced_mal_id and forced_mal_id > 0 then + return forced_mal_id, "(forced-mal-id)" + end + if type(title) == "string" and title:match("^%d+$") then + local numeric = tonumber(title) + if numeric and numeric > 0 then + return numeric, title + end + end + if type(title) ~= "string" or title == "" then + return nil, nil + end + + local lookup = title + if season and season > 1 then + lookup = string.format("%s Season %d", lookup, season) + end + local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup) + local mal_json, mal_error = run_json_curl(mal_url) + if not mal_json then + subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error)) + return nil, lookup + end + local categories = mal_json.categories + if type(categories) ~= "table" then + return nil, lookup + end + + 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 + 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 "-") + ) + ) + return tonumber(best_item.id), lookup + end + return nil, lookup + end + + local function set_intro_chapters(intro_start, intro_end) + if type(intro_start) ~= "number" or type(intro_end) ~= "number" then + return + end + local current = mp.get_property_native("chapter-list") + local chapters = {} + if type(current) == "table" then + for _, chapter in ipairs(current) do + local title = type(chapter) == "table" and chapter.title or nil + if type(title) ~= "string" or not title:match("^AniSkip ") then + chapters[#chapters + 1] = chapter + end + end + end + chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" } + chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" } + table.sort(chapters, function(a, b) + local a_time = type(a) == "table" and tonumber(a.time) or 0 + local b_time = type(b) == "table" and tonumber(b.time) or 0 + return a_time < b_time + end) + mp.set_property_native("chapter-list", chapters) + end + + local function remove_aniskip_chapters() + local current = mp.get_property_native("chapter-list") + if type(current) ~= "table" then + return + end + local chapters = {} + local changed = false + for _, chapter in ipairs(current) do + local title = type(chapter) == "table" and chapter.title or nil + if type(title) == "string" and title:match("^AniSkip ") then + changed = true + else + chapters[#chapters + 1] = chapter + end + end + if changed then + mp.set_property_native("chapter-list", chapters) + end + end + + local function clear_aniskip_state() + state.aniskip.prompt_shown = false + state.aniskip.found = false + state.aniskip.mal_id = nil + state.aniskip.title = nil + state.aniskip.episode = nil + state.aniskip.intro_start = nil + state.aniskip.intro_end = nil + remove_aniskip_chapters() + end + + local function skip_intro_now() + if not state.aniskip.found then + show_osd("Intro skip unavailable") + return + end + local intro_start = state.aniskip.intro_start + local intro_end = state.aniskip.intro_end + if type(intro_start) ~= "number" or type(intro_end) ~= "number" then + show_osd("Intro markers missing") + return + end + local now = mp.get_property_number("time-pos") + if type(now) ~= "number" then + show_osd("Skip unavailable") + return + end + local epsilon = 0.35 + if now < (intro_start - epsilon) or now > (intro_end + epsilon) then + show_osd("Skip intro only during intro") + return + end + mp.set_property_number("time-pos", intro_end) + show_osd("Skipped intro") + end + + local function update_intro_button_visibility() + if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then + return + end + local now = mp.get_property_number("time-pos") + if type(now) ~= "number" then + return + end + local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1) + local intro_start = state.aniskip.intro_start or -1 + local hint_window_end = intro_start + 3 + if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then + local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k" + local message = string.format(opts.aniskip_button_text, key) + mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3) + state.aniskip.prompt_shown = true + end + end + + local function apply_aniskip_payload(mal_id, title, episode, payload) + local results = payload and payload.results + if type(results) ~= "table" then + return false + end + for _, item in ipairs(results) do + if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then + local intro_start = tonumber(item.interval.start_time) + local intro_end = tonumber(item.interval.end_time) + if intro_start and intro_end and intro_end > intro_start then + state.aniskip.found = true + state.aniskip.mal_id = mal_id + state.aniskip.title = title + state.aniskip.episode = episode + state.aniskip.intro_start = intro_start + state.aniskip.intro_end = intro_end + state.aniskip.prompt_shown = false + set_intro_chapters(intro_start, intro_end) + subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode)) + return true + end + end + end + return false + end + + local function fetch_aniskip_for_current_media() + if not environment.is_subminer_app_running() then + subminer_log("debug", "lifecycle", "Skipping aniskip lookup: SubMiner app not running") + return + end + + clear_aniskip_state() + if not opts.aniskip_enabled then + return + end + local title, episode, season = resolve_title_and_episode() + local media_title_fallback = cleanup_title(mp.get_property("media-title")) + local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "") + local path_fallback = cleanup_title(mp.get_property("path") or "") + local lookup_titles = {} + local seen_titles = {} + local function push_lookup_title(candidate) + if type(candidate) ~= "string" then + return + end + local trimmed = candidate:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return + end + local key = trimmed:lower() + if seen_titles[key] then + return + end + seen_titles[key] = true + lookup_titles[#lookup_titles + 1] = trimmed + end + push_lookup_title(title) + push_lookup_title(media_title_fallback) + push_lookup_title(filename_fallback) + push_lookup_title(path_fallback) + + subminer_log( + "info", + "aniskip", + string.format( + 'Query context: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)', + tostring(title or ""), + tostring(season or "-"), + tostring(episode or "-"), + tostring(opts.aniskip_title or ""), + tostring(opts.aniskip_season or "-"), + tostring(opts.aniskip_episode or "-"), + tostring(opts.aniskip_mal_id or "-"), + #lookup_titles + ) + ) + local mal_id, mal_lookup = nil, nil + for index, lookup_title in ipairs(lookup_titles) do + subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title)) + local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season) + if attempt_mal_id then + mal_id = attempt_mal_id + mal_lookup = attempt_lookup + break + end + mal_lookup = attempt_lookup or mal_lookup + end + if not mal_id then + subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or ""))) + return + end + local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode) + subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url)) + local payload, fetch_error = run_json_curl(url) + if not payload then + subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error)) + return + end + if payload.found ~= true then + subminer_log("info", "aniskip", "AniSkip: no skip windows found") + return + end + if not apply_aniskip_payload(mal_id, title, episode, payload) then + subminer_log("info", "aniskip", "AniSkip payload did not include OP interval") + end + end + + 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/aniskip_match.lua b/plugin/subminer/aniskip_match.lua new file mode 100644 index 0000000..b33d830 --- /dev/null +++ b/plugin/subminer/aniskip_match.lua @@ -0,0 +1,150 @@ +local M = {} + +local function normalize_for_match(value) + if type(value) ~= "string" then + return "" + end + return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or "" +end + +local MATCH_STOPWORDS = { + the = true, + this = true, + that = true, + world = true, + animated = true, + series = true, + season = true, + no = true, + on = true, + ["and"] = true, +} + +local function tokenize_match_words(value) + local normalized = normalize_for_match(value) + local tokens = {} + for token in normalized:gmatch("%S+") do + if #token >= 3 and not MATCH_STOPWORDS[token] then + tokens[#tokens + 1] = token + end + end + return tokens +end + +local function token_set(tokens) + local set = {} + for _, token in ipairs(tokens) do + set[token] = true + end + return set +end + +function M.title_overlap_score(expected_title, candidate_title) + local expected = normalize_for_match(expected_title) + local candidate = normalize_for_match(candidate_title) + if expected == "" or candidate == "" then + return 0 + end + if candidate:find(expected, 1, true) then + return 120 + end + local expected_tokens = tokenize_match_words(expected_title) + local candidate_tokens = token_set(tokenize_match_words(candidate_title)) + if #expected_tokens == 0 then + return 0 + end + local score = 0 + local matched = 0 + for _, token in ipairs(expected_tokens) do + if candidate_tokens[token] then + score = score + 30 + matched = matched + 1 + else + score = score - 20 + end + end + if matched == 0 then + score = score - 80 + end + local coverage = matched / #expected_tokens + if #expected_tokens >= 2 then + if coverage >= 0.8 then + score = score + 30 + elseif coverage >= 0.6 then + score = score + 10 + else + score = score - 50 + end + elseif coverage >= 1 then + score = score + 10 + end + return score +end + +local function has_any_sequel_marker(candidate_title) + local normalized = normalize_for_match(candidate_title) + if normalized == "" then + return false + end + local markers = { + "season 2", + "season 3", + "season 4", + "2nd season", + "3rd season", + "4th season", + "second season", + "third season", + "fourth season", + " ii ", + " iii ", + " iv ", + } + local padded = " " .. normalized .. " " + for _, marker in ipairs(markers) do + if padded:find(marker, 1, true) then + return true + end + end + return false +end + +function M.season_signal_score(requested_season, candidate_title) + local season = tonumber(requested_season) + if not season or season < 1 then + return 0 + end + local normalized = " " .. normalize_for_match(candidate_title) .. " " + if normalized == " " then + return 0 + end + + if season == 1 then + return has_any_sequel_marker(candidate_title) and -60 or 20 + end + + local numeric_marker = string.format(" season %d ", season) + local ordinal_marker = string.format(" %dth season ", season) + local roman_markers = { + [2] = { " ii ", " second season ", " 2nd season " }, + [3] = { " iii ", " third season ", " 3rd season " }, + [4] = { " iv ", " fourth season ", " 4th season " }, + [5] = { " v ", " fifth season ", " 5th season " }, + } + + if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then + return 40 + end + local aliases = roman_markers[season] or {} + for _, marker in ipairs(aliases) do + if normalized:find(marker, 1, true) then + return 40 + end + end + if has_any_sequel_marker(candidate_title) then + return -20 + end + return 5 +end + +return M diff --git a/plugin/subminer/binary.lua b/plugin/subminer/binary.lua new file mode 100644 index 0000000..5a065c5 --- /dev/null +++ b/plugin/subminer/binary.lua @@ -0,0 +1,151 @@ +local M = {} + +function M.create(ctx) + local utils = ctx.utils + local opts = ctx.opts + local state = ctx.state + local environment = ctx.environment + local subminer_log = ctx.log.subminer_log + + local function normalize_binary_path_candidate(candidate) + if type(candidate) ~= "string" then + return nil + end + local trimmed = candidate:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return nil + end + if #trimmed >= 2 then + local first = trimmed:sub(1, 1) + local last = trimmed:sub(-1) + if (first == '"' and last == '"') or (first == "'" and last == "'") then + trimmed = trimmed:sub(2, -2) + end + end + return trimmed ~= "" and trimmed or nil + end + + local function binary_candidates_from_app_path(app_path) + return { + utils.join_path(app_path, "Contents", "MacOS", "SubMiner"), + utils.join_path(app_path, "Contents", "MacOS", "subminer"), + } + end + + local function file_exists(path) + local info = utils.file_info(path) + if not info then + return false + end + if info.is_dir ~= nil then + return not info.is_dir + end + return true + end + + local function resolve_binary_candidate(candidate) + local normalized = normalize_binary_path_candidate(candidate) + if not normalized then + return nil + end + + if file_exists(normalized) then + return normalized + end + + if not normalized:lower():find("%.app") then + return nil + end + + local app_root = normalized + if not app_root:lower():match("%.app$") then + app_root = normalized:match("(.+%.app)") + end + if not app_root then + return nil + end + + for _, path in ipairs(binary_candidates_from_app_path(app_root)) do + if file_exists(path) then + return path + end + end + + return nil + end + + local function find_binary_override() + local candidates = { + resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")), + resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")), + } + + for _, path in ipairs(candidates) do + if path and path ~= "" then + return path + end + end + + return nil + end + + local function find_binary() + local override = find_binary_override() + if override then + return override + end + + local configured = resolve_binary_candidate(opts.binary_path) + if configured then + return configured + end + + local search_paths = { + "/Applications/SubMiner.app/Contents/MacOS/SubMiner", + utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"), + "C:\\Program Files\\SubMiner\\SubMiner.exe", + "C:\\Program Files (x86)\\SubMiner\\SubMiner.exe", + "C:\\SubMiner\\SubMiner.exe", + utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"), + "/opt/SubMiner/SubMiner.AppImage", + "/usr/local/bin/SubMiner", + "/usr/bin/SubMiner", + } + + for _, path in ipairs(search_paths) do + if file_exists(path) then + subminer_log("info", "binary", "Found binary at: " .. path) + return path + end + end + + return nil + end + + local function ensure_binary_available() + if state.binary_available and state.binary_path and file_exists(state.binary_path) then + return true + end + + local discovered = find_binary() + if discovered then + state.binary_path = discovered + state.binary_available = true + return true + end + + state.binary_path = nil + state.binary_available = false + return false + end + + return { + normalize_binary_path_candidate = normalize_binary_path_candidate, + file_exists = file_exists, + find_binary = find_binary, + ensure_binary_available = ensure_binary_available, + is_windows = environment.is_windows, + } +end + +return M diff --git a/plugin/subminer/bootstrap.lua b/plugin/subminer/bootstrap.lua new file mode 100644 index 0000000..7ae3f26 --- /dev/null +++ b/plugin/subminer/bootstrap.lua @@ -0,0 +1,41 @@ +local M = {} + +function M.init() + local input = require("mp.input") + local mp = require("mp") + local msg = require("mp.msg") + local options_lib = require("mp.options") + local utils = require("mp.utils") + + local options_helper = require("options") + local environment = require("environment").create({ mp = mp }) + local opts = options_helper.load(options_lib, environment.default_socket_path()) + local state = require("state").new() + + local ctx = { + input = input, + mp = mp, + msg = msg, + utils = utils, + opts = opts, + state = state, + options_helper = options_helper, + environment = environment, + } + + ctx.log = require("log").create(ctx) + ctx.binary = require("binary").create(ctx) + ctx.aniskip = require("aniskip").create(ctx) + ctx.hover = require("hover").create(ctx) + ctx.process = require("process").create(ctx) + ctx.ui = require("ui").create(ctx) + ctx.messages = require("messages").create(ctx) + ctx.lifecycle = require("lifecycle").create(ctx) + + ctx.ui.register_keybindings() + ctx.messages.register_script_messages() + ctx.lifecycle.register_lifecycle_hooks() + ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded") +end + +return M diff --git a/plugin/subminer/environment.lua b/plugin/subminer/environment.lua new file mode 100644 index 0000000..0f94fe5 --- /dev/null +++ b/plugin/subminer/environment.lua @@ -0,0 +1,130 @@ +local M = {} + +function M.create(ctx) + local mp = ctx.mp + + local detected_backend = nil + + 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 is_subminer_process_running() + local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" } + local result = mp.command_native({ + name = "subprocess", + args = command, + playback_only = false, + capture_stdout = true, + capture_stderr = false, + }) + if not result or type(result.stdout) ~= "string" or result.status ~= 0 then + return false + end + + local process_list = result.stdout: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 is_subminer_app_running() + return is_subminer_process_running() + 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, + detect_backend = detect_backend, + } +end + +return M diff --git a/plugin/subminer/hover.lua b/plugin/subminer/hover.lua new file mode 100644 index 0000000..3db9db0 --- /dev/null +++ b/plugin/subminer/hover.lua @@ -0,0 +1,436 @@ +local M = {} + +local DEFAULT_HOVER_BASE_COLOR = "FFFFFF" +local DEFAULT_HOVER_COLOR = "C6A0F6" + +function M.create(ctx) + local mp = ctx.mp + local msg = ctx.msg + local utils = ctx.utils + local state = ctx.state + + local function to_hex_color(input) + if type(input) ~= "string" then + return nil + end + + local hex = input:gsub("[%#%']", ""):gsub("^0x", "") + if #hex ~= 6 and #hex ~= 3 then + return nil + end + if #hex == 3 then + return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3) + end + return hex + end + + local function fix_ass_color(input, fallback) + local hex = to_hex_color(input) + if not hex then + return fallback or DEFAULT_HOVER_BASE_COLOR + end + local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6) + return b .. g .. r + end + + local function escape_ass_text(text) + return (text or ""):gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}"):gsub("\n", "\\N") + end + + local function resolve_osd_dimensions() + local width = mp.get_property_number("osd-width", 0) or 0 + local height = mp.get_property_number("osd-height", 0) or 0 + + if width <= 0 or height <= 0 then + local osd_dims = mp.get_property_native("osd-dimensions") + if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then + width = osd_dims.w + end + if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then + height = osd_dims.h + end + end + + if width <= 0 then + width = 1280 + end + if height <= 0 then + height = 720 + end + + return width, height + end + + local function resolve_metrics() + local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36 + local sub_scale = mp.get_property_number("sub-scale", 1) or 1 + local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true + local sub_pos = mp.get_property_number("sub-pos", 100) or 100 + local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0 + local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif" + local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0 + local sub_bold = mp.get_property_bool("sub-bold", false) == true + local sub_italic = mp.get_property_bool("sub-italic", false) == true + local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2 + local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0 + local osd_w, osd_h = resolve_osd_dimensions() + local window_scale = 1 + if sub_scale_by_window and osd_h > 0 then + window_scale = osd_h / 720 + end + local effective_margin_y = sub_margin_y * window_scale + + return { + font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale, + pos = sub_pos, + margin_y = effective_margin_y, + font = sub_font, + spacing = sub_spacing, + bold = sub_bold, + italic = sub_italic, + border = sub_border_size * window_scale, + shadow = sub_shadow_offset * window_scale, + base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR), + hover_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_COLOR), + } + end + + local function get_subtitle_ass_property() + local ass_text = mp.get_property("sub-text/ass") + if type(ass_text) == "string" and ass_text ~= "" then + return ass_text + end + ass_text = mp.get_property("sub-text-ass") + if type(ass_text) == "string" and ass_text ~= "" then + return ass_text + end + return nil + end + + local function plain_text_and_ass_map(text) + local plain = {} + local map = {} + local plain_len = 0 + local i = 1 + local text_len = #text + + while i <= text_len do + local ch = text:sub(i, i) + if ch == "{" then + local close = text:find("}", i + 1, true) + if not close then + break + end + i = close + 1 + elseif ch == "\\" then + local esc = text:sub(i + 1, i + 1) + if esc == "N" or esc == "n" then + plain_len = plain_len + 1 + plain[plain_len] = "\n" + map[plain_len] = i + i = i + 2 + elseif esc == "h" then + plain_len = plain_len + 1 + plain[plain_len] = " " + map[plain_len] = i + i = i + 2 + elseif esc == "{" then + plain_len = plain_len + 1 + plain[plain_len] = "{" + map[plain_len] = i + i = i + 2 + elseif esc == "}" then + plain_len = plain_len + 1 + plain[plain_len] = "}" + map[plain_len] = i + i = i + 2 + elseif esc == "\\" then + plain_len = plain_len + 1 + plain[plain_len] = "\\" + map[plain_len] = i + i = i + 2 + else + local seq_end = i + 1 + while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do + seq_end = seq_end + 1 + end + if text:sub(seq_end, seq_end) == "(" then + local close = text:find(")", seq_end, true) + if close then + i = close + 1 + else + i = seq_end + 1 + end + else + i = seq_end + 1 + end + end + else + plain_len = plain_len + 1 + plain[plain_len] = ch + map[plain_len] = i + i = i + 1 + end + end + + return table.concat(plain), map + end + + local function find_hover_span(payload, plain) + local source_len = #plain + local cursor = 1 + for _, token in ipairs(payload.tokens or {}) do + if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then + goto continue + end + + local token_text = token.text + local start_pos = nil + local end_pos = nil + + if type(token.startPos) == "number" and type(token.endPos) == "number" then + if token.startPos >= 0 and token.endPos >= token.startPos then + local candidate_start = token.startPos + 1 + local candidate_stop = token.endPos + if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then + start_pos = candidate_start + end_pos = candidate_stop + end + end + end + + if not start_pos or not end_pos then + local fallback_start, fallback_stop = plain:find(token_text, cursor, true) + if not fallback_start then + fallback_start, fallback_stop = plain:find(token_text, 1, true) + end + start_pos, end_pos = fallback_start, fallback_stop + end + + if start_pos and end_pos then + if token.index == payload.hoveredTokenIndex then + return start_pos, end_pos + end + cursor = end_pos + 1 + end + + ::continue:: + end + + return nil + end + + local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color) + if hover_start == nil or hover_end == nil then + return raw_ass + end + + local raw_open_idx = plain_map[hover_start] or 1 + local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1) + if raw_open_idx < 1 then + raw_open_idx = 1 + end + if raw_close_idx < 1 then + raw_close_idx = 1 + end + if raw_open_idx > #raw_ass + 1 then + raw_open_idx = #raw_ass + 1 + end + if raw_close_idx > #raw_ass + 1 then + raw_close_idx = #raw_ass + 1 + end + + local open_tag = string.format("{\\1c&H%s&}", hover_color) + local close_tag = string.format("{\\1c&H%s&}", base_color) + local changes = { + { idx = raw_open_idx, tag = open_tag }, + { idx = raw_close_idx, tag = close_tag }, + } + table.sort(changes, function(a, b) + return a.idx < b.idx + end) + + local output = {} + local cursor = 1 + for _, change in ipairs(changes) do + if change.idx > #raw_ass + 1 then + change.idx = #raw_ass + 1 + end + if change.idx < 1 then + change.idx = 1 + end + if change.idx > cursor then + output[#output + 1] = raw_ass:sub(cursor, change.idx - 1) + end + output[#output + 1] = change.tag + cursor = change.idx + end + if cursor <= #raw_ass then + output[#output + 1] = raw_ass:sub(cursor) + end + + return table.concat(output) + end + + local function build_hover_subtitle_content(payload) + local source_ass = get_subtitle_ass_property() + if type(source_ass) == "string" and source_ass ~= "" then + state.hover_highlight.cached_ass = source_ass + else + source_ass = state.hover_highlight.cached_ass + end + if type(source_ass) ~= "string" or source_ass == "" then + return nil + end + + local plain_source, plain_map = plain_text_and_ass_map(source_ass) + if type(plain_source) ~= "string" or plain_source == "" then + return nil + end + + local hover_start, hover_end = find_hover_span(payload, plain_source) + if not hover_start or not hover_end then + return nil + end + + local metrics = resolve_metrics() + local hover_color = fix_ass_color(payload.colors and payload.colors.hover or nil, metrics.hover_color) + local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color) + return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color) + end + + local function clear_hover_overlay() + if state.hover_highlight.clear_timer then + state.hover_highlight.clear_timer:kill() + state.hover_highlight.clear_timer = nil + end + if state.hover_highlight.overlay_active then + if type(state.hover_highlight.saved_sub_visibility) == "string" then + mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility) + else + mp.set_property("sub-visibility", "yes") + end + if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then + mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility) + end + state.hover_highlight.saved_sub_visibility = nil + state.hover_highlight.saved_secondary_sub_visibility = nil + state.hover_highlight.overlay_active = false + end + mp.set_osd_ass(0, 0, "") + state.hover_highlight.payload = nil + state.hover_highlight.revision = -1 + state.hover_highlight.cached_ass = nil + state.hover_highlight.last_hover_update_ts = 0 + end + + local function schedule_hover_clear(delay_seconds) + if state.hover_highlight.clear_timer then + state.hover_highlight.clear_timer:kill() + state.hover_highlight.clear_timer = nil + end + state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function() + state.hover_highlight.clear_timer = nil + clear_hover_overlay() + end) + end + + local function render_hover_overlay(payload) + if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then + clear_hover_overlay() + return + end + + local ass = build_hover_subtitle_content(payload) + if not ass then + return + end + + local osd_w, osd_h = resolve_osd_dimensions() + local metrics = resolve_metrics() + local osd_dims = mp.get_property_native("osd-dimensions") + local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0 + local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0 + local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0 + local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0 + local usable_w = math.max(1, osd_w - ml - mr) + local usable_h = math.max(1, osd_h - mt - mb) + local anchor_x = math.floor(ml + usable_w / 2) + local baseline_adjust = (metrics.border + metrics.shadow) * 5 + local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust) + local font_size = math.max(8, metrics.font_size) + local anchor_tag = string.format( + "{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}", + anchor_x, + anchor_y, + escape_ass_text(metrics.font), + font_size, + metrics.bold and 1 or 0, + metrics.italic and 1 or 0, + metrics.spacing, + metrics.border, + metrics.shadow, + metrics.base_color + ) + if not state.hover_highlight.overlay_active then + state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility") + state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility") + mp.set_property("sub-visibility", "no") + mp.set_property("secondary-sub-visibility", "no") + state.hover_highlight.overlay_active = true + end + mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass) + end + + local function handle_hover_message(payload_json) + local parsed, parse_error = utils.parse_json(payload_json) + if not parsed then + msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error)) + clear_hover_overlay() + return + end + + if type(parsed.revision) ~= "number" then + clear_hover_overlay() + return + end + + if parsed.revision < state.hover_highlight.revision then + return + end + + if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then + if state.hover_highlight.clear_timer then + state.hover_highlight.clear_timer:kill() + state.hover_highlight.clear_timer = nil + end + state.hover_highlight.revision = parsed.revision + state.hover_highlight.payload = parsed + state.hover_highlight.last_hover_update_ts = mp.get_time() or 0 + render_hover_overlay(state.hover_highlight.payload) + return + end + + local now = mp.get_time() or 0 + local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0) + state.hover_highlight.revision = parsed.revision + state.hover_highlight.payload = nil + if state.hover_highlight.overlay_active then + if elapsed_since_hover > 0.35 then + return + end + schedule_hover_clear(0.08) + else + clear_hover_overlay() + end + end + + return { + HOVER_MESSAGE_NAME = "subminer-hover-token", + HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token", + handle_hover_message = handle_hover_message, + clear_hover_overlay = clear_hover_overlay, + } +end + +return M diff --git a/plugin/subminer/init.lua b/plugin/subminer/init.lua new file mode 100644 index 0000000..0371a87 --- /dev/null +++ b/plugin/subminer/init.lua @@ -0,0 +1,7 @@ +local M = {} + +function M.init() + require("bootstrap").init() +end + +return M diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua new file mode 100644 index 0000000..7e29fce --- /dev/null +++ b/plugin/subminer/lifecycle.lua @@ -0,0 +1,79 @@ +local M = {} + +function M.create(ctx) + local mp = ctx.mp + local opts = ctx.opts + local state = ctx.state + local environment = ctx.environment + local binary = ctx.binary + 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 on_file_loaded() + if not environment.is_subminer_app_running() then + aniskip.clear_aniskip_state() + subminer_log("debug", "lifecycle", "Skipping file load hooks: SubMiner app not running") + return true + end + + aniskip.clear_aniskip_state() + aniskip.fetch_aniskip_for_current_media() + state.binary_path = binary.find_binary() + if state.binary_path then + state.binary_available = true + subminer_log("info", "lifecycle", "SubMiner ready (binary: " .. state.binary_path .. ")") + local should_auto_start = options_helper.coerce_bool(opts.auto_start, false) + if should_auto_start then + process.start_overlay() + end + else + state.binary_available = false + subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled") + if opts.binary_path ~= "" then + subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist") + end + end + end + + local function on_shutdown() + aniskip.clear_aniskip_state() + hover.clear_hover_overlay() + if (state.overlay_running or state.texthooker_running) and state.binary_available then + subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process") + 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", hover.clear_hover_overlay) + mp.register_event("end-file", hover.clear_hover_overlay) + mp.register_event("shutdown", hover.clear_hover_overlay) + mp.register_event("end-file", aniskip.clear_aniskip_state) + mp.register_event("shutdown", aniskip.clear_aniskip_state) + 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/log.lua b/plugin/subminer/log.lua new file mode 100644 index 0000000..6554a52 --- /dev/null +++ b/plugin/subminer/log.lua @@ -0,0 +1,60 @@ +local M = {} + +local LOG_LEVEL_PRIORITY = { + debug = 10, + info = 20, + warn = 30, + error = 40, +} + +function M.create(ctx) + local mp = ctx.mp + local msg = ctx.msg + local opts = ctx.opts + + local function normalize_log_level(level) + local normalized = (level or "info"):lower() + if LOG_LEVEL_PRIORITY[normalized] then + return normalized + end + return "info" + end + + local function should_log(level) + local current = normalize_log_level(opts.log_level) + local target = normalize_log_level(level) + return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current] + end + + local function subminer_log(level, scope, message) + if not should_log(level) then + return + end + local timestamp = os.date("%Y-%m-%d %H:%M:%S") + local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message) + if level == "error" then + msg.error(line) + elseif level == "warn" then + msg.warn(line) + elseif level == "debug" then + msg.debug(line) + else + msg.info(line) + end + end + + local function show_osd(message) + if opts.osd_messages then + mp.osd_message("SubMiner: " .. message, 3) + end + end + + return { + normalize_log_level = normalize_log_level, + should_log = should_log, + subminer_log = subminer_log, + show_osd = show_osd, + } +end + +return M diff --git a/plugin/subminer/main.lua b/plugin/subminer/main.lua new file mode 100644 index 0000000..7ee7840 --- /dev/null +++ b/plugin/subminer/main.lua @@ -0,0 +1,9 @@ +local mp = require("mp") + +local script_dir = mp.get_script_directory() or "." +local module_patterns = script_dir .. "/?.lua;" .. script_dir .. "/?/init.lua;" +if not package.path:find(module_patterns, 1, true) then + package.path = module_patterns .. package.path +end + +require("init").init() diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua new file mode 100644 index 0000000..fcba142 --- /dev/null +++ b/plugin/subminer/messages.lua @@ -0,0 +1,36 @@ +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", process.start_overlay_from_script_message) + mp.register_script_message("subminer-stop", process.stop_overlay) + mp.register_script_message("subminer-toggle", process.toggle_overlay) + mp.register_script_message("subminer-toggle-invisible", process.toggle_invisible_overlay) + mp.register_script_message("subminer-show-invisible", process.show_invisible_overlay) + mp.register_script_message("subminer-hide-invisible", process.hide_invisible_overlay) + mp.register_script_message("subminer-menu", ui.show_menu) + mp.register_script_message("subminer-options", process.open_options) + mp.register_script_message("subminer-restart", process.restart_overlay) + mp.register_script_message("subminer-status", process.check_status) + mp.register_script_message("subminer-aniskip-refresh", aniskip.fetch_aniskip_for_current_media) + mp.register_script_message("subminer-skip-intro", aniskip.skip_intro_now) + 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/plugin/subminer/options.lua b/plugin/subminer/options.lua new file mode 100644 index 0000000..7bf2abf --- /dev/null +++ b/plugin/subminer/options.lua @@ -0,0 +1,47 @@ +local M = {} + +function M.load(options_lib, default_socket_path) + local opts = { + binary_path = "", + socket_path = default_socket_path, + texthooker_enabled = true, + texthooker_port = 5174, + backend = "auto", + auto_start = true, + auto_start_overlay = false, + auto_start_visible_overlay = false, + auto_start_invisible_overlay = "platform-default", + osd_messages = true, + log_level = "info", + aniskip_enabled = true, + aniskip_title = "", + aniskip_season = "", + aniskip_mal_id = "", + aniskip_episode = "", + aniskip_show_button = true, + aniskip_button_text = "You can skip by pressing %s", + aniskip_button_key = "y-k", + aniskip_button_duration = 3, + } + + options_lib.read_options(opts, "subminer") + return opts +end + +function M.coerce_bool(value, fallback) + if type(value) == "boolean" then + return value + end + if type(value) == "string" then + local normalized = value:lower() + if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then + return true + end + if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then + return false + end + end + return fallback +end + +return M diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua new file mode 100644 index 0000000..7b63c29 --- /dev/null +++ b/plugin/subminer/process.lua @@ -0,0 +1,449 @@ +local M = {} + +function M.create(ctx) + local mp = ctx.mp + local opts = ctx.opts + local state = ctx.state + local binary = ctx.binary + local environment = ctx.environment + local options_helper = ctx.options_helper + local subminer_log = ctx.log.subminer_log + local show_osd = ctx.log.show_osd + local normalize_log_level = ctx.log.normalize_log_level + + local function resolve_backend(override_backend) + local selected = override_backend + if selected == nil or selected == "" then + selected = opts.backend + end + if selected == "auto" then + return environment.detect_backend() + end + return selected + end + + local function build_command_args(action, overrides) + overrides = overrides or {} + local args = { state.binary_path } + + table.insert(args, "--" .. action) + local log_level = normalize_log_level(overrides.log_level or opts.log_level) + if log_level ~= "info" then + table.insert(args, "--log-level") + table.insert(args, log_level) + end + + local needs_start_context = action == "start" + if needs_start_context then + local backend = resolve_backend(overrides.backend) + if backend and backend ~= "" then + table.insert(args, "--backend") + table.insert(args, backend) + end + + local socket_path = overrides.socket_path or opts.socket_path + table.insert(args, "--socket") + table.insert(args, socket_path) + end + + return args + end + + local function run_control_command(action) + local args = build_command_args(action) + subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + return result and result.status == 0 + end + + local function parse_start_script_message_overrides(...) + local overrides = {} + for i = 1, select("#", ...) do + local token = select(i, ...) + if type(token) == "string" and token ~= "" then + local key, value = token:match("^([%w_%-]+)=(.+)$") + if key and value then + local normalized_key = key:lower() + if normalized_key == "backend" then + local backend = value:lower() + if backend == "auto" or backend == "hyprland" or backend == "sway" or backend == "x11" or backend == "macos" then + overrides.backend = backend + end + elseif normalized_key == "socket" or normalized_key == "socket_path" then + overrides.socket_path = value + elseif normalized_key == "texthooker" or normalized_key == "texthooker_enabled" then + local parsed = options_helper.coerce_bool(value, nil) + if parsed ~= nil then + overrides.texthooker_enabled = parsed + end + elseif normalized_key == "log-level" or normalized_key == "log_level" then + overrides.log_level = normalize_log_level(value) + end + end + end + end + return overrides + end + + local function resolve_visible_overlay_startup() + local visible = options_helper.coerce_bool(opts.auto_start_visible_overlay, false) + if options_helper.coerce_bool(opts.auto_start_overlay, false) then + visible = true + end + return visible + end + + local function resolve_invisible_overlay_startup() + local raw = opts.auto_start_invisible_overlay + if type(raw) == "boolean" then + return raw + end + + local mode = type(raw) == "string" and raw:lower() or "platform-default" + if mode == "visible" or mode == "show" or mode == "yes" or mode == "true" or mode == "on" then + return true + end + if mode == "hidden" or mode == "hide" or mode == "no" or mode == "false" or mode == "off" then + return false + end + + return not environment.is_linux() + end + + local function apply_startup_overlay_preferences() + local should_show_visible = resolve_visible_overlay_startup() + local should_show_invisible = resolve_invisible_overlay_startup() + + local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay" + if not run_control_command(visible_action) then + subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action) + end + + local invisible_action = should_show_invisible and "show-invisible-overlay" or "hide-invisible-overlay" + if not run_control_command(invisible_action) then + subminer_log("warn", "process", "Failed to apply invisible startup action: " .. invisible_action) + end + + state.invisible_overlay_visible = should_show_invisible + end + + local function build_texthooker_args() + local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) } + local log_level = normalize_log_level(opts.log_level) + if log_level ~= "info" then + table.insert(args, "--log-level") + table.insert(args, log_level) + end + return args + end + + local function ensure_texthooker_running(callback) + if not opts.texthooker_enabled then + callback() + return + end + if state.texthooker_running then + callback() + return + end + + local args = build_texthooker_args() + subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " ")) + state.texthooker_running = true + + mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or (result and result.status ~= 0) then + state.texthooker_running = false + subminer_log("warn", "texthooker", "Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")) + end + end) + + mp.add_timeout(0.35, callback) + end + + local function start_overlay(overrides) + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + if state.overlay_running then + subminer_log("info", "process", "Overlay already running") + show_osd("Already running") + return + end + + overrides = overrides or {} + local texthooker_enabled = overrides.texthooker_enabled + if texthooker_enabled == nil then + texthooker_enabled = opts.texthooker_enabled + end + + local function launch_overlay() + local args = build_command_args("start", overrides) + subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " ")) + show_osd("Starting...") + state.overlay_running = true + + mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or (result and result.status ~= 0) then + state.overlay_running = false + subminer_log("error", "process", "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")) + show_osd("Overlay start failed") + end + end) + + mp.add_timeout(0.6, function() + apply_startup_overlay_preferences() + end) + end + + if texthooker_enabled then + ensure_texthooker_running(launch_overlay) + else + launch_overlay() + end + end + + local function start_overlay_from_script_message(...) + local overrides = parse_start_script_message_overrides(...) + start_overlay(overrides) + end + + local function stop_overlay() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local args = build_command_args("stop") + subminer_log("info", "process", "Stopping overlay: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + state.overlay_running = false + state.texthooker_running = false + if result.status == 0 then + subminer_log("info", "process", "Overlay stopped") + else + subminer_log("warn", "process", "Stop command returned non-zero status: " .. tostring(result.status)) + end + show_osd("Stopped") + end + + local function toggle_overlay() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + local args = build_command_args("toggle") + subminer_log("info", "process", "Toggling overlay: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if result and result.status ~= 0 then + subminer_log("warn", "process", "Toggle command failed") + show_osd("Toggle failed") + end + end + + local function toggle_invisible_overlay() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + local args = build_command_args("toggle-invisible-overlay") + subminer_log("info", "process", "Toggling invisible overlay: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if result and result.status ~= 0 then + subminer_log("warn", "process", "Invisible toggle command failed") + show_osd("Invisible toggle failed") + return + end + state.invisible_overlay_visible = not state.invisible_overlay_visible + show_osd("Invisible overlay: " .. (state.invisible_overlay_visible and "visible" or "hidden")) + end + + local function show_invisible_overlay() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + local args = build_command_args("show-invisible-overlay") + subminer_log("info", "process", "Showing invisible overlay: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if result and result.status ~= 0 then + subminer_log("warn", "process", "Show invisible command failed") + show_osd("Show invisible failed") + return + end + state.invisible_overlay_visible = true + show_osd("Invisible overlay: visible") + end + + local function hide_invisible_overlay() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + local args = build_command_args("hide-invisible-overlay") + subminer_log("info", "process", "Hiding invisible overlay: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if result and result.status ~= 0 then + subminer_log("warn", "process", "Hide invisible command failed") + show_osd("Hide invisible failed") + return + end + state.invisible_overlay_visible = false + show_osd("Invisible overlay: hidden") + end + + local function open_options() + if not state.binary_available then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + local args = build_command_args("settings") + subminer_log("info", "process", "Opening options: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if result.status == 0 then + subminer_log("info", "process", "Options window opened") + show_osd("Options opened") + else + subminer_log("warn", "process", "Failed to open options") + show_osd("Failed to open options") + end + end + + local function restart_overlay() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + subminer_log("info", "process", "Restarting overlay...") + show_osd("Restarting...") + + local stop_args = build_command_args("stop") + mp.command_native({ + name = "subprocess", + args = stop_args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + state.overlay_running = false + state.texthooker_running = false + + ensure_texthooker_running(function() + local start_args = build_command_args("start") + subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " ")) + + state.overlay_running = true + mp.command_native_async({ + name = "subprocess", + args = start_args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or (result and result.status ~= 0) then + state.overlay_running = false + subminer_log("error", "process", "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")) + show_osd("Restart failed") + else + show_osd("Restarted successfully") + end + end) + end) + end + + local function check_status() + if not state.binary_available then + show_osd("Status: binary not found") + return + end + local status = state.overlay_running and "running" or "stopped" + show_osd("Status: overlay is " .. status) + subminer_log("info", "process", "Status check: overlay is " .. status) + end + + return { + build_command_args = build_command_args, + run_control_command = run_control_command, + parse_start_script_message_overrides = parse_start_script_message_overrides, + apply_startup_overlay_preferences = apply_startup_overlay_preferences, + ensure_texthooker_running = ensure_texthooker_running, + start_overlay = start_overlay, + start_overlay_from_script_message = start_overlay_from_script_message, + stop_overlay = stop_overlay, + toggle_overlay = toggle_overlay, + toggle_invisible_overlay = toggle_invisible_overlay, + show_invisible_overlay = show_invisible_overlay, + hide_invisible_overlay = hide_invisible_overlay, + open_options = open_options, + restart_overlay = restart_overlay, + check_status = check_status, + } +end + +return M diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua new file mode 100644 index 0000000..c4aa1fe --- /dev/null +++ b/plugin/subminer/state.lua @@ -0,0 +1,33 @@ +local M = {} + +function M.new() + return { + overlay_running = false, + texthooker_running = false, + overlay_process = nil, + binary_available = false, + binary_path = nil, + invisible_overlay_visible = false, + hover_highlight = { + revision = -1, + payload = nil, + saved_sub_visibility = nil, + saved_secondary_sub_visibility = nil, + overlay_active = false, + cached_ass = nil, + clear_timer = nil, + last_hover_update_ts = 0, + }, + aniskip = { + mal_id = nil, + title = nil, + episode = nil, + intro_start = nil, + intro_end = nil, + found = false, + prompt_shown = false, + }, + } +end + +return M diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua new file mode 100644 index 0000000..b7dbf32 --- /dev/null +++ b/plugin/subminer/ui.lua @@ -0,0 +1,76 @@ +local M = {} + +function M.create(ctx) + local mp = ctx.mp + local input = ctx.input + local opts = ctx.opts + local state = ctx.state + local process = ctx.process + local aniskip = ctx.aniskip + local subminer_log = ctx.log.subminer_log + local show_osd = ctx.log.show_osd + + local function show_menu() + if not state.binary_available then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local items = { + "Start overlay", + "Stop overlay", + "Toggle overlay", + "Toggle invisible overlay", + "Open options", + "Restart overlay", + "Check status", + } + + local actions = { + process.start_overlay, + process.stop_overlay, + process.toggle_overlay, + process.toggle_invisible_overlay, + process.open_options, + process.restart_overlay, + process.check_status, + } + + input.select({ + prompt = "SubMiner: ", + items = items, + submit = function(index) + if index and actions[index] then + actions[index]() + end + end, + }) + end + + local function register_keybindings() + mp.add_key_binding("y-s", "subminer-start", process.start_overlay) + mp.add_key_binding("y-S", "subminer-stop", process.stop_overlay) + mp.add_key_binding("y-t", "subminer-toggle", process.toggle_overlay) + mp.add_key_binding("y-i", "subminer-toggle-invisible", process.toggle_invisible_overlay) + mp.add_key_binding("y-I", "subminer-show-invisible", process.show_invisible_overlay) + mp.add_key_binding("y-u", "subminer-hide-invisible", process.hide_invisible_overlay) + mp.add_key_binding("y-y", "subminer-menu", show_menu) + mp.add_key_binding("y-o", "subminer-options", process.open_options) + mp.add_key_binding("y-r", "subminer-restart", process.restart_overlay) + mp.add_key_binding("y-c", "subminer-status", process.check_status) + if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then + mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", aniskip.skip_intro_now) + end + if opts.aniskip_button_key ~= "y-k" then + mp.add_key_binding("y-k", "subminer-skip-intro-fallback", aniskip.skip_intro_now) + end + end + + return { + show_menu = show_menu, + register_keybindings = register_keybindings, + } +end + +return M diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 23c6366..198b59b 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -80,9 +80,20 @@ local function run_plugin_scenario(config) end function mp.commandv(...) end function mp.set_property_native(...) end + function mp.set_property(...) end + function mp.set_osd_ass(...) end + function mp.get_property_number(_name, default) + return default + end + function mp.get_property_bool(_name, default) + return default + end function mp.get_script_name() return "subminer" end + function mp.get_script_directory() + return "plugin/subminer" + end return mp end @@ -122,6 +133,30 @@ local function run_plugin_scenario(config) package.loaded["mp.msg"] = nil package.loaded["mp.options"] = nil package.loaded["mp.utils"] = nil + for key, _ in pairs(package.loaded) do + if key:match("^subminer") then + package.loaded[key] = nil + end + end + local plugin_modules = { + "init", + "bootstrap", + "options", + "state", + "log", + "binary", + "environment", + "process", + "aniskip", + "aniskip_match", + "hover", + "ui", + "messages", + "lifecycle", + } + for _, module_name in ipairs(plugin_modules) do + package.loaded[module_name] = nil + end package.preload["mp"] = function() return mp @@ -154,7 +189,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 @@ -180,6 +215,18 @@ local function find_start_call(async_calls) return nil end +local function has_command_flag(calls, flag) + for _, call in ipairs(calls) do + local args = call.args or {} + for i = 1, #args do + if args[i] == flag then + return true + end + end + end + return false +end + local function has_sync_command(sync_calls, executable) for _, call in ipairs(sync_calls) do local args = call.args or {} @@ -202,8 +249,11 @@ 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") + assert_true(recorded.script_messages["subminer-stop"] ~= nil, "subminer-stop script message not registered") recorded.script_messages["subminer-start"]("texthooker=no") assert_true(find_start_call(recorded.async_calls) ~= nil, "expected cold-start to invoke --start command when process is absent") + recorded.script_messages["subminer-stop"]() + assert_true(has_command_flag(recorded.sync_calls, "--stop"), "expected stop message to invoke --stop command") assert_true( not has_sync_command(recorded.sync_calls, "ps"), "expected cold-start start command to avoid synchronous process list scan"