From abb0abdb92eae85a7a984b3ed829ac80067855dd Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 3 Apr 2026 01:09:22 -0700 Subject: [PATCH] update --- .config/mpv/scripts/modernz.lua | 1 + .config/mpv/scripts/mpv-youtube-queue | 1 + .../mpv/scripts/sponsorblock_shared/CLAUDE.md | 9 + .config/mpv/scripts/subminer/aniskip.lua | 758 ++++++++++++++++++ .../mpv/scripts/subminer/aniskip_match.lua | 150 ++++ .config/mpv/scripts/subminer/binary.lua | 301 +++++++ .config/mpv/scripts/subminer/bootstrap.lua | 80 ++ .config/mpv/scripts/subminer/environment.lua | 210 +++++ .config/mpv/scripts/subminer/hover.lua | 431 ++++++++++ .config/mpv/scripts/subminer/init.lua | 7 + .config/mpv/scripts/subminer/lifecycle.lua | 111 +++ .config/mpv/scripts/subminer/log.lua | 67 ++ .config/mpv/scripts/subminer/main.lua | 30 + .config/mpv/scripts/subminer/messages.lua | 57 ++ .config/mpv/scripts/subminer/options.lua | 72 ++ .config/mpv/scripts/subminer/process.lua | 542 +++++++++++++ .config/mpv/scripts/subminer/state.lua | 39 + .config/mpv/scripts/subminer/ui.lua | 114 +++ .../mpv/scripts/ytdl-preload.lua##os.Linux | 405 ---------- 19 files changed, 2980 insertions(+), 405 deletions(-) create mode 120000 .config/mpv/scripts/modernz.lua create mode 120000 .config/mpv/scripts/mpv-youtube-queue create mode 100644 .config/mpv/scripts/sponsorblock_shared/CLAUDE.md create mode 100644 .config/mpv/scripts/subminer/aniskip.lua create mode 100644 .config/mpv/scripts/subminer/aniskip_match.lua create mode 100644 .config/mpv/scripts/subminer/binary.lua create mode 100644 .config/mpv/scripts/subminer/bootstrap.lua create mode 100644 .config/mpv/scripts/subminer/environment.lua create mode 100644 .config/mpv/scripts/subminer/hover.lua create mode 100644 .config/mpv/scripts/subminer/init.lua create mode 100644 .config/mpv/scripts/subminer/lifecycle.lua create mode 100644 .config/mpv/scripts/subminer/log.lua create mode 100644 .config/mpv/scripts/subminer/main.lua create mode 100644 .config/mpv/scripts/subminer/messages.lua create mode 100644 .config/mpv/scripts/subminer/options.lua create mode 100644 .config/mpv/scripts/subminer/process.lua create mode 100644 .config/mpv/scripts/subminer/state.lua create mode 100644 .config/mpv/scripts/subminer/ui.lua delete mode 100644 .config/mpv/scripts/ytdl-preload.lua##os.Linux diff --git a/.config/mpv/scripts/modernz.lua b/.config/mpv/scripts/modernz.lua new file mode 120000 index 0000000..b620497 --- /dev/null +++ b/.config/mpv/scripts/modernz.lua @@ -0,0 +1 @@ +../../mpv-modules/ModernZ/modernz.lua \ No newline at end of file diff --git a/.config/mpv/scripts/mpv-youtube-queue b/.config/mpv/scripts/mpv-youtube-queue new file mode 120000 index 0000000..2b0e32e --- /dev/null +++ b/.config/mpv/scripts/mpv-youtube-queue @@ -0,0 +1 @@ +../../../projects/lua/mpv-youtube-queue/mpv-youtube-queue \ No newline at end of file diff --git a/.config/mpv/scripts/sponsorblock_shared/CLAUDE.md b/.config/mpv/scripts/sponsorblock_shared/CLAUDE.md new file mode 100644 index 0000000..a4c85d5 --- /dev/null +++ b/.config/mpv/scripts/sponsorblock_shared/CLAUDE.md @@ -0,0 +1,9 @@ + +# Recent Activity + +### Feb 5, 2026 + +| ID | Time | T | Title | Read | +|----|------|---|-------|------| +| #142 | 8:49 PM | 🔵 | SponsorBlock Integration Found in MPV Configuration | ~233 | + \ No newline at end of file diff --git a/.config/mpv/scripts/subminer/aniskip.lua b/.config/mpv/scripts/subminer/aniskip.lua new file mode 100644 index 0000000..dacf512 --- /dev/null +++ b/.config/mpv/scripts/subminer/aniskip.lua @@ -0,0 +1,758 @@ +local M = {} +local matcher = require("aniskip_match") +local DEFAULT_ANISKIP_BUTTON_KEY = "TAB" + +function M.create(ctx) + local mp = ctx.mp + local utils = ctx.utils + local opts = ctx.opts + local state = ctx.state + local environment = ctx.environment + local subminer_log = ctx.log.subminer_log + local show_osd = ctx.log.show_osd + local request_generation = 0 + local mal_lookup_cache = {} + local payload_cache = {} + local title_context_cache = {} + local base64_reverse = {} + local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + + for i = 1, #base64_chars do + base64_reverse[base64_chars:sub(i, i)] = i - 1 + 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 is_remote_media_path() + local media_path = mp.get_property("path") + if type(media_path) ~= "string" then + return false + end + local trimmed = media_path:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return false + end + return trimmed:match("^%a[%w+.-]*://") ~= nil + end + + local function parse_json_payload(text) + if type(text) ~= "string" then + return nil + end + local parsed, parse_error = utils.parse_json(text) + if type(parsed) == "table" then + return parsed + end + return nil, parse_error + end + + local function decode_base64(input) + if type(input) ~= "string" then + return nil + end + local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/") + cleaned = cleaned:match("^%s*(.-)%s*$") or "" + if cleaned == "" then + return nil + end + if #cleaned % 4 == 1 then + return nil + end + if #cleaned % 4 ~= 0 then + cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4)) + end + if not cleaned:match("^[A-Za-z0-9+/%=]+$") then + return nil + end + local out = {} + local out_len = 0 + for index = 1, #cleaned, 4 do + local c1 = cleaned:sub(index, index) + local c2 = cleaned:sub(index + 1, index + 1) + local c3 = cleaned:sub(index + 2, index + 2) + local c4 = cleaned:sub(index + 3, index + 3) + local v1 = base64_reverse[c1] + local v2 = base64_reverse[c2] + if not v1 or not v2 then + return nil + end + local v3 = c3 == "=" and 0 or base64_reverse[c3] + local v4 = c4 == "=" and 0 or base64_reverse[c4] + if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then + return nil + end + local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4) + local b1 = math.floor(n / 65536) + local remaining = n % 65536 + local b2 = math.floor(remaining / 256) + local b3 = remaining % 256 + out_len = out_len + 1 + out[out_len] = string.char(b1) + if c3 ~= "=" then + out_len = out_len + 1 + out[out_len] = string.char(b2) + end + if c4 ~= "=" then + out_len = out_len + 1 + out[out_len] = string.char(b3) + end + end + return table.concat(out) + end + + local function resolve_launcher_payload() + local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or "" + local trimmed = raw_payload:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return nil + end + + local parsed, parse_error = parse_json_payload(trimmed) + if type(parsed) == "table" then + return parsed + end + + local url_decoded = trimmed:gsub("%%(%x%x)", function(hex) + local value = tonumber(hex, 16) + if value then + return string.char(value) + end + return "%" + end) + if url_decoded ~= trimmed then + parsed, parse_error = parse_json_payload(url_decoded) + if type(parsed) == "table" then + return parsed + end + end + + local b64_decoded = decode_base64(trimmed) + if type(b64_decoded) == "string" and b64_decoded ~= "" then + parsed, parse_error = parse_json_payload(b64_decoded) + if type(parsed) == "table" then + return parsed + end + end + + subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable")) + return nil + end + + local function run_json_curl_async(url, callback) + mp.command_native_async({ + name = "subprocess", + args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url }, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then + local detail = error or (result and result.stderr) or "curl failed" + callback(nil, detail) + return + end + local parsed, parse_error = utils.parse_json(result.stdout) + if type(parsed) ~= "table" then + callback(nil, parse_error or "invalid json") + return + end + callback(parsed, nil) + end) + end + + local function parse_episode_hint(text) + if type(text) ~= "string" or text == "" then + return nil + end + local patterns = { + "[Ss]%d+[Ee](%d+)", + "[Ee][Pp]?[%s%._%-]*(%d+)", + "[%s%._%-]+(%d+)[%s%._%-]+", + } + for _, pattern in ipairs(patterns) do + local token = text:match(pattern) + if token then + local episode = tonumber(token) + if episode and episode > 0 and episode < 10000 then + return episode + end + end + end + return nil + end + + local function cleanup_title(raw) + if type(raw) ~= "string" then + return nil + end + local cleaned = raw + cleaned = cleaned:gsub("%b[]", " ") + cleaned = cleaned:gsub("%b()", " ") + cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ") + cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ") + cleaned = cleaned:gsub("[%._%-]+", " ") + cleaned = cleaned:gsub("%s+", " ") + cleaned = cleaned:match("^%s*(.-)%s*$") or "" + if cleaned == "" then + return nil + end + return cleaned + end + + local function extract_show_title_from_path(media_path) + if type(media_path) ~= "string" or media_path == "" then + return nil + end + local normalized = media_path:gsub("\\", "/") + local segments = {} + for segment in normalized:gmatch("[^/]+") do + segments[#segments + 1] = segment + end + for index = 1, #segments do + local segment = segments[index] or "" + if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then + local prior = segments[index - 1] + local cleaned = cleanup_title(prior or "") + if cleaned and cleaned ~= "" then + return cleaned + end + end + end + return nil + end + + local function resolve_title_and_episode() + local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or "" + local forced_season = tonumber(opts.aniskip_season) + local forced_episode = tonumber(opts.aniskip_episode) + local media_title = mp.get_property("media-title") + local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or "" + local path = mp.get_property("path") or "" + local cache_key = table.concat({ + tostring(forced_title or ""), + tostring(forced_season or ""), + tostring(forced_episode or ""), + tostring(media_title or ""), + tostring(filename or ""), + tostring(path or ""), + }, "\31") + local cached = title_context_cache[cache_key] + if type(cached) == "table" then + return cached.title, cached.episode, cached.season + end + local path_show_title = extract_show_title_from_path(path) + local candidate_title = nil + if path_show_title and path_show_title ~= "" then + candidate_title = path_show_title + elseif forced_title ~= "" then + candidate_title = forced_title + else + candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path) + end + local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1 + title_context_cache[cache_key] = { + title = candidate_title, + episode = episode, + season = forced_season, + } + return candidate_title, episode, forced_season + end + + local function select_best_mal_item(items, title, season) + if type(items) ~= "table" then + return nil + end + local best_item = nil + local best_score = -math.huge + for _, item in ipairs(items) do + if type(item) == "table" and tonumber(item.id) then + local candidate_name = tostring(item.name or "") + local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name) + if score > best_score then + best_score = score + best_item = item + end + end + end + return best_item + end + + local function resolve_mal_id_async(title, season, request_id, callback) + local forced_mal_id = tonumber(opts.aniskip_mal_id) + if forced_mal_id and forced_mal_id > 0 then + callback(forced_mal_id, "(forced-mal-id)") + return + end + if type(title) == "string" and title:match("^%d+$") then + local numeric = tonumber(title) + if numeric and numeric > 0 then + callback(numeric, title) + return + end + end + if type(title) ~= "string" or title == "" then + callback(nil, nil) + return + end + + local lookup = title + if season and season > 1 then + lookup = string.format("%s Season %d", lookup, season) + end + local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-")) + local cached = mal_lookup_cache[cache_key] + if cached ~= nil then + if cached == false then + callback(nil, lookup) + else + callback(cached, lookup) + end + return + end + + local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup) + run_json_curl_async(mal_url, function(mal_json, mal_error) + if request_id ~= request_generation then + return + end + if not mal_json then + subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error)) + callback(nil, lookup) + return + end + local categories = mal_json.categories + if type(categories) ~= "table" then + mal_lookup_cache[cache_key] = false + callback(nil, lookup) + return + end + + local all_items = {} + for _, category in ipairs(categories) do + if type(category) == "table" and type(category.items) == "table" then + for _, item in ipairs(category.items) do + all_items[#all_items + 1] = item + end + end + end + local best_item = select_best_mal_item(all_items, title, season) + if best_item and tonumber(best_item.id) then + local matched_id = tonumber(best_item.id) + mal_lookup_cache[cache_key] = matched_id + subminer_log( + "info", + "aniskip", + string.format( + 'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s', + tostring(best_item.id), + tostring(best_item.name or ""), + tostring(season or "-") + ) + ) + callback(matched_id, lookup) + return + end + mal_lookup_cache[cache_key] = false + callback(nil, lookup) + end) + end + + local function set_intro_chapters(intro_start, intro_end) + if type(intro_start) ~= "number" or type(intro_end) ~= "number" then + return + end + local current = mp.get_property_native("chapter-list") + local chapters = {} + if type(current) == "table" then + for _, chapter in ipairs(current) do + local title = type(chapter) == "table" and chapter.title or nil + if type(title) ~= "string" or not title:match("^AniSkip ") then + chapters[#chapters + 1] = chapter + end + end + end + chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" } + chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" } + table.sort(chapters, function(a, b) + local a_time = type(a) == "table" and tonumber(a.time) or 0 + local b_time = type(b) == "table" and tonumber(b.time) or 0 + return a_time < b_time + end) + mp.set_property_native("chapter-list", chapters) + end + + local function remove_aniskip_chapters() + local current = mp.get_property_native("chapter-list") + if type(current) ~= "table" then + return + end + local chapters = {} + local changed = false + for _, chapter in ipairs(current) do + local title = type(chapter) == "table" and chapter.title or nil + if type(title) == "string" and title:match("^AniSkip ") then + changed = true + else + chapters[#chapters + 1] = chapter + end + end + if changed then + mp.set_property_native("chapter-list", chapters) + end + end + + local function reset_aniskip_fields() + state.aniskip.prompt_shown = false + state.aniskip.found = false + state.aniskip.mal_id = nil + state.aniskip.title = nil + state.aniskip.episode = nil + state.aniskip.intro_start = nil + state.aniskip.intro_end = nil + state.aniskip.payload = nil + state.aniskip.payload_source = nil + remove_aniskip_chapters() + end + + local function clear_aniskip_state() + request_generation = request_generation + 1 + reset_aniskip_fields() + end + + local function skip_intro_now() + if not state.aniskip.found then + show_osd("Intro skip unavailable") + return + end + local intro_start = state.aniskip.intro_start + local intro_end = state.aniskip.intro_end + if type(intro_start) ~= "number" or type(intro_end) ~= "number" then + show_osd("Intro markers missing") + return + end + local now = mp.get_property_number("time-pos") + if type(now) ~= "number" then + show_osd("Skip unavailable") + return + end + local epsilon = 0.35 + if now < (intro_start - epsilon) or now > (intro_end + epsilon) then + show_osd("Skip intro only during intro") + return + end + mp.set_property_number("time-pos", intro_end) + show_osd("Skipped intro") + end + + local function update_intro_button_visibility() + if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then + return + end + local now = mp.get_property_number("time-pos") + if type(now) ~= "number" then + return + end + local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1) + local intro_start = state.aniskip.intro_start or -1 + local hint_window_end = intro_start + 3 + if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then + local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY + 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 %s, ep %s)", + intro_start, + intro_end, + tostring(mal_id or "-"), + tostring(episode or "-") + ) + ) + return true + end + end + end + return false + end + + local function has_launcher_payload() + return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil + end + + local function is_launcher_context() + local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or "" + if forced_title ~= "" then + return true + end + local forced_mal_id = tonumber(opts.aniskip_mal_id) + if forced_mal_id and forced_mal_id > 0 then + return true + end + local forced_episode = tonumber(opts.aniskip_episode) + if forced_episode and forced_episode > 0 then + return true + end + local forced_season = tonumber(opts.aniskip_season) + if forced_season and forced_season > 0 then + return true + end + if has_launcher_payload() then + return true + end + return false + end + + local function should_fetch_aniskip_async(trigger_source, callback) + if is_remote_media_path() then + callback(false, "remote-url") + return + end + if trigger_source == "script-message" or trigger_source == "overlay-start" then + callback(true, trigger_source) + return + end + if is_launcher_context() then + callback(true, "launcher-context") + return + end + if type(environment.is_subminer_app_running_async) == "function" then + environment.is_subminer_app_running_async(function(running) + if running then + callback(true, "subminer-app-running") + else + callback(false, "subminer-context-missing") + end + end) + return + end + if environment.is_subminer_app_running() then + callback(true, "subminer-app-running") + return + end + callback(false, "subminer-context-missing") + end + + local function resolve_lookup_titles(primary_title) + local media_title_fallback = cleanup_title(mp.get_property("media-title")) + local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "") + local path_fallback = cleanup_title(mp.get_property("path") or "") + local lookup_titles = {} + local seen_titles = {} + local function push_lookup_title(candidate) + if type(candidate) ~= "string" then + return + end + local trimmed = candidate:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return + end + local key = trimmed:lower() + if seen_titles[key] then + return + end + seen_titles[key] = true + lookup_titles[#lookup_titles + 1] = trimmed + end + push_lookup_title(primary_title) + push_lookup_title(media_title_fallback) + push_lookup_title(filename_fallback) + push_lookup_title(path_fallback) + return lookup_titles + end + + local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup) + local current_index = index or 1 + local current_lookup = last_lookup + if current_index > #lookup_titles then + callback(nil, current_lookup) + return + end + local lookup_title = lookup_titles[current_index] + subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title)) + resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup) + if request_id ~= request_generation then + return + end + if mal_id then + callback(mal_id, lookup) + return + end + resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup) + end) + end + + local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback) + local payload_cache_key = string.format("%d:%d", mal_id, episode) + local cached_payload = payload_cache[payload_cache_key] + if cached_payload ~= nil then + if cached_payload == false then + callback(nil, nil, true) + else + callback(cached_payload, nil, true) + end + return + end + local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode) + subminer_log("info", "aniskip", string.format("AniSkip URL=%s", url)) + run_json_curl_async(url, function(payload, fetch_error) + if request_id ~= request_generation then + return + end + if not payload then + callback(nil, fetch_error, false) + return + end + if payload.found ~= true then + payload_cache[payload_cache_key] = false + callback(nil, nil, false) + return + end + payload_cache[payload_cache_key] = payload + callback(payload, nil, false) + end) + end + + local function fetch_payload_from_launcher(payload, mal_id, title, episode) + if not payload then + return false + end + state.aniskip.payload = payload + state.aniskip.payload_source = "launcher" + state.aniskip.mal_id = mal_id + state.aniskip.title = title + state.aniskip.episode = episode + return apply_aniskip_payload(mal_id, title, episode, payload) + end + + local function fetch_aniskip_for_current_media(trigger_source) + local trigger = type(trigger_source) == "string" and trigger_source or "manual" + if not opts.aniskip_enabled then + clear_aniskip_state() + return + end + + should_fetch_aniskip_async(trigger, function(allowed, reason) + if not allowed then + subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason)) + return + end + + request_generation = request_generation + 1 + local request_id = request_generation + reset_aniskip_fields() + local title, episode, season = resolve_title_and_episode() + local lookup_titles = resolve_lookup_titles(title) + local launcher_payload = resolve_launcher_payload() + if launcher_payload then + local launcher_mal_id = tonumber(opts.aniskip_mal_id) + if not launcher_mal_id then + launcher_mal_id = nil + end + if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then + subminer_log( + "info", + "aniskip", + string.format( + "Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)", + tostring(title or ""), + tostring(season or "-"), + tostring(episode or "-") + ) + ) + return + end + subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available") + return + end + + subminer_log( + "info", + "aniskip", + string.format( + 'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)', + tostring(trigger), + tostring(reason or "-"), + tostring(title or ""), + tostring(season or "-"), + tostring(episode or "-"), + tostring(opts.aniskip_title or ""), + tostring(opts.aniskip_season or "-"), + tostring(opts.aniskip_episode or "-"), + tostring(opts.aniskip_mal_id or "-"), + #lookup_titles + ) + ) + + resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup) + if request_id ~= request_generation then + return + end + if not mal_id then + subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or ""))) + return + end + subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or ""))) + fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error) + if request_id ~= request_generation then + return + end + if not payload then + if fetch_error then + subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error)) + else + subminer_log("info", "aniskip", "AniSkip: no skip windows found") + end + return + end + state.aniskip.payload = payload + state.aniskip.payload_source = "remote" + if not apply_aniskip_payload(mal_id, title, episode, payload) then + subminer_log("info", "aniskip", "AniSkip payload did not include OP interval") + end + end) + end) + end) + end + + return { + clear_aniskip_state = clear_aniskip_state, + skip_intro_now = skip_intro_now, + update_intro_button_visibility = update_intro_button_visibility, + fetch_aniskip_for_current_media = fetch_aniskip_for_current_media, + } +end + +return M diff --git a/.config/mpv/scripts/subminer/aniskip_match.lua b/.config/mpv/scripts/subminer/aniskip_match.lua new file mode 100644 index 0000000..b33d830 --- /dev/null +++ b/.config/mpv/scripts/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/.config/mpv/scripts/subminer/binary.lua b/.config/mpv/scripts/subminer/binary.lua new file mode 100644 index 0000000..9b231eb --- /dev/null +++ b/.config/mpv/scripts/subminer/binary.lua @@ -0,0 +1,301 @@ +local M = {} + +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 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) + if environment.is_windows() then + return { + utils.join_path(app_path, "SubMiner.exe"), + utils.join_path(app_path, "subminer.exe"), + } + end + + 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 directory_exists(path) + local info = utils.file_info(path) + return info ~= nil and info.is_dir == 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 environment.is_windows() then + if not normalized:lower():match("%.exe$") then + local with_exe = normalized .. ".exe" + if file_exists(with_exe) then + return with_exe + end + end + + if directory_exists(normalized) then + for _, path in ipairs(binary_candidates_from_app_path(normalized)) do + if file_exists(path) then + return path + end + end + end + + return nil + 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() + for _, env_name in ipairs({ "SUBMINER_APPIMAGE_PATH", "SUBMINER_BINARY_PATH" }) do + local path = resolve_binary_candidate(os.getenv(env_name)) + if path and path ~= "" then + return path + end + end + + return nil + end + + local function add_search_path(search_paths, candidate) + if type(candidate) == "string" and candidate ~= "" then + search_paths[#search_paths + 1] = candidate + end + end + + local function trim_subprocess_stdout(value) + if type(value) ~= "string" then + return nil + end + local trimmed = value:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return nil + end + return trimmed + end + + local function find_windows_binary_via_system_lookup() + if not environment.is_windows() then + return nil + end + if not mp or type(mp.command_native) ~= "function" then + return nil + end + + local script = [=[ +function Emit-FirstExistingPath { + param([string[]]$Candidates) + + foreach ($candidate in $Candidates) { + if ([string]::IsNullOrWhiteSpace($candidate)) { + continue + } + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + Write-Output $candidate + exit 0 + } + } +} + +$runningProcess = Get-CimInstance Win32_Process | + Where-Object { $_.Name -ieq 'SubMiner.exe' -or $_.Name -ieq 'subminer.exe' } | + Select-Object -First 1 -Property ExecutablePath, CommandLine +if ($null -ne $runningProcess) { + Emit-FirstExistingPath @($runningProcess.ExecutablePath) +} + +$localAppData = [Environment]::GetFolderPath('LocalApplicationData') +$programFiles = [Environment]::GetFolderPath('ProgramFiles') +$programFilesX86 = ${env:ProgramFiles(x86)} + +Emit-FirstExistingPath @( + $(if (-not [string]::IsNullOrWhiteSpace($localAppData)) { Join-Path $localAppData 'Programs\SubMiner\SubMiner.exe' } else { $null }), + $(if (-not [string]::IsNullOrWhiteSpace($programFiles)) { Join-Path $programFiles 'SubMiner\SubMiner.exe' } else { $null }), + $(if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { Join-Path $programFilesX86 'SubMiner\SubMiner.exe' } else { $null }), + 'C:\SubMiner\SubMiner.exe' +) + +foreach ($registryPath in @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe' +)) { + try { + $appPath = (Get-ItemProperty -Path $registryPath -ErrorAction Stop).'(default)' + Emit-FirstExistingPath @($appPath) + } catch { + } +} + +try { + $commandPath = Get-Command SubMiner.exe -ErrorAction Stop | Select-Object -First 1 -ExpandProperty Source + Emit-FirstExistingPath @($commandPath) +} catch { +} +]=] + + local result = mp.command_native({ + name = "subprocess", + args = { + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + }, + playback_only = false, + capture_stdout = true, + capture_stderr = false, + }) + if not result or result.status ~= 0 then + return nil + end + + local candidate = trim_subprocess_stdout(result.stdout) + if not candidate then + return nil + end + + return resolve_binary_candidate(candidate) + 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 system_lookup_binary = find_windows_binary_via_system_lookup() + if system_lookup_binary then + subminer_log("info", "binary", "Found Windows binary via system lookup at: " .. system_lookup_binary) + return system_lookup_binary + end + + local home = os.getenv("HOME") or os.getenv("USERPROFILE") or "" + local app_data = os.getenv("APPDATA") or "" + local app_data_local = app_data ~= "" and app_data:gsub("[/\\][Rr][Oo][Aa][Mm][Ii][Nn][Gg]$", "\\Local") or "" + local local_app_data = os.getenv("LOCALAPPDATA") or utils.join_path(home, "AppData", "Local") + local program_files = os.getenv("ProgramFiles") or "C:\\Program Files" + local program_files_x86 = os.getenv("ProgramFiles(x86)") or "C:\\Program Files (x86)" + local search_paths = {} + + if environment.is_windows() then + add_search_path(search_paths, utils.join_path(app_data_local, "Programs", "SubMiner", "SubMiner.exe")) + add_search_path(search_paths, utils.join_path(local_app_data, "Programs", "SubMiner", "SubMiner.exe")) + add_search_path(search_paths, utils.join_path(program_files, "SubMiner", "SubMiner.exe")) + add_search_path(search_paths, utils.join_path(program_files_x86, "SubMiner", "SubMiner.exe")) + add_search_path(search_paths, "C:\\SubMiner\\SubMiner.exe") + else + add_search_path(search_paths, "/Applications/SubMiner.app/Contents/MacOS/SubMiner") + add_search_path(search_paths, utils.join_path(home, "Applications", "SubMiner.app", "Contents", "MacOS", "SubMiner")) + add_search_path(search_paths, utils.join_path(home, ".local", "bin", "SubMiner.AppImage")) + add_search_path(search_paths, "/opt/SubMiner/SubMiner.AppImage") + add_search_path(search_paths, "/usr/local/bin/SubMiner") + add_search_path(search_paths, "/usr/local/bin/subminer") + add_search_path(search_paths, "/usr/bin/SubMiner") + add_search_path(search_paths, "/usr/bin/subminer") + end + + 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/.config/mpv/scripts/subminer/bootstrap.lua b/.config/mpv/scripts/subminer/bootstrap.lua new file mode 100644 index 0000000..62eaabf --- /dev/null +++ b/.config/mpv/scripts/subminer/bootstrap.lua @@ -0,0 +1,80 @@ +local M = {} +local BOOTSTRAP_GUARD_KEY = "__subminer_plugin_bootstrapped" + +function M.init() + if rawget(_G, BOOTSTRAP_GUARD_KEY) == true then + return + end + rawset(_G, BOOTSTRAP_GUARD_KEY, true) + + 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, + } + + local instances = {} + + local function lazy_instance(key, factory) + if instances[key] == nil then + instances[key] = factory() + end + return instances[key] + end + + local function make_lazy_proxy(key, factory) + return setmetatable({}, { + __index = function(_, member) + return lazy_instance(key, factory)[member] + end, + }) + end + + ctx.log = make_lazy_proxy("log", function() + return require("log").create(ctx) + end) + ctx.binary = make_lazy_proxy("binary", function() + return require("binary").create(ctx) + end) + ctx.aniskip = make_lazy_proxy("aniskip", function() + return require("aniskip").create(ctx) + end) + ctx.hover = make_lazy_proxy("hover", function() + return require("hover").create(ctx) + end) + ctx.process = make_lazy_proxy("process", function() + return require("process").create(ctx) + end) + ctx.ui = make_lazy_proxy("ui", function() + return require("ui").create(ctx) + end) + ctx.messages = make_lazy_proxy("messages", function() + return require("messages").create(ctx) + end) + ctx.lifecycle = make_lazy_proxy("lifecycle", function() + return require("lifecycle").create(ctx) + end) + + 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/.config/mpv/scripts/subminer/environment.lua b/.config/mpv/scripts/subminer/environment.lua new file mode 100644 index 0000000..3aa6f79 --- /dev/null +++ b/.config/mpv/scripts/subminer/environment.lua @@ -0,0 +1,210 @@ +local M = {} + +function M.create(ctx) + local mp = ctx.mp + + local detected_backend = nil + local app_running_cache_value = nil + local app_running_cache_time = nil + local app_running_check_inflight = false + local app_running_waiters = {} + local APP_RUNNING_CACHE_TTL_SECONDS = 2 + + local function is_windows() + return package.config:sub(1, 1) == "\\" + end + + local function is_macos() + local platform = mp.get_property("platform") or "" + if platform == "macos" or platform == "darwin" then + return true + end + local ostype = os.getenv("OSTYPE") or "" + return ostype:find("darwin") ~= nil + end + + local function default_socket_path() + if is_windows() then + return "\\\\.\\pipe\\subminer-socket" + end + return "/tmp/subminer-socket" + end + + local function is_linux() + return not is_windows() and not is_macos() + end + + local function now_seconds() + if type(mp.get_time) == "function" then + local value = tonumber(mp.get_time()) + if value then + return value + end + end + return os.time() + end + + local function process_list_has_subminer(raw_process_list) + if type(raw_process_list) ~= "string" then + return false + end + local process_list = raw_process_list:lower() + for line in process_list:gmatch("[^\n]+") do + if is_windows() then + local image = line:match('^"([^"]+)","') + if not image then + image = line:match('^"([^"]+)"') + end + if not image then + goto continue + end + if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then + return true + end + if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then + return true + end + else + local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)") + if not argv0 then + goto continue + end + if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then + goto continue + end + local exe = argv0:match("([^/\\]+)$") or argv0 + if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then + return true + end + if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then + return true + end + end + + ::continue:: + end + return false + end + + local function process_scan_command() + if is_windows() then + return { "tasklist", "/FO", "CSV", "/NH" } + end + return { "ps", "-A", "-o", "args=" } + end + + local function is_subminer_process_running() + local result = mp.command_native({ + name = "subprocess", + args = process_scan_command(), + playback_only = false, + capture_stdout = true, + capture_stderr = false, + }) + if not result or result.status ~= 0 then + return false + end + return process_list_has_subminer(result.stdout) + end + + local function flush_app_running_waiters(value) + local waiters = app_running_waiters + app_running_waiters = {} + for _, waiter in ipairs(waiters) do + waiter(value) + end + end + + local function is_subminer_app_running_async(callback, opts) + opts = opts or {} + local force_refresh = opts.force_refresh == true + local now = now_seconds() + if not force_refresh and app_running_cache_value ~= nil and app_running_cache_time ~= nil then + if (now - app_running_cache_time) <= APP_RUNNING_CACHE_TTL_SECONDS then + callback(app_running_cache_value) + return + end + end + + app_running_waiters[#app_running_waiters + 1] = callback + if app_running_check_inflight then + return + end + app_running_check_inflight = true + + mp.command_native_async({ + name = "subprocess", + args = process_scan_command(), + playback_only = false, + capture_stdout = true, + capture_stderr = false, + }, function(success, result) + app_running_check_inflight = false + local running = false + if success and result and result.status == 0 then + running = process_list_has_subminer(result.stdout) + end + app_running_cache_value = running + app_running_cache_time = now_seconds() + flush_app_running_waiters(running) + end) + end + + local function is_subminer_app_running() + local running = is_subminer_process_running() + app_running_cache_value = running + app_running_cache_time = now_seconds() + return running + end + + local function set_subminer_app_running_cache(running) + app_running_cache_value = running == true + app_running_cache_time = now_seconds() + end + + local function detect_backend() + if detected_backend then + return detected_backend + end + + local backend = nil + local subminer_log = ctx.log and ctx.log.subminer_log or function() end + + if is_macos() then + backend = "macos" + elseif is_windows() then + backend = nil + elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then + backend = "hyprland" + elseif os.getenv("SWAYSOCK") then + backend = "sway" + elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then + backend = "x11" + else + subminer_log("warn", "backend", "Could not detect window manager, falling back to x11") + backend = "x11" + end + + detected_backend = backend + if backend then + subminer_log("info", "backend", "Detected backend: " .. backend) + else + subminer_log("info", "backend", "No backend detected") + end + return backend + end + + return { + is_windows = is_windows, + is_macos = is_macos, + is_linux = is_linux, + default_socket_path = default_socket_path, + is_subminer_process_running = is_subminer_process_running, + is_subminer_app_running = is_subminer_app_running, + is_subminer_app_running_async = is_subminer_app_running_async, + set_subminer_app_running_cache = set_subminer_app_running_cache, + detect_backend = detect_backend, + } +end + +return M diff --git a/.config/mpv/scripts/subminer/hover.lua b/.config/mpv/scripts/subminer/hover.lua new file mode 100644 index 0000000..6a24e41 --- /dev/null +++ b/.config/mpv/scripts/subminer/hover.lua @@ -0,0 +1,431 @@ +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 sanitize_hover_ass_color(input, fallback_rgb) + local fallback = fix_ass_color(fallback_rgb or DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR) + local converted = fix_ass_color(input, fallback) + if converted == "000000" then + return fallback + end + return converted + 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 = sanitize_hover_ass_color(nil, 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 before = raw_ass:sub(1, raw_open_idx - 1) + local hovered = raw_ass:sub(raw_open_idx, raw_close_idx - 1) + local after = raw_ass:sub(raw_close_idx) + local hover_suffix = string.format("\\1c&H%s&", hover_color) + + -- Keep hover foreground stable even when inline ASS override tags (\1c/\c/\r) appear inside token. + hovered = hovered:gsub("{([^}]*)}", function(inner) + if inner:find("\\1c&H", 1, true) or inner:find("\\c&H", 1, true) or inner:find("\\r", 1, true) then + return "{" .. inner .. hover_suffix .. "}" + end + return "{" .. inner .. "}" + end) + + local open_tag = string.format("{\\1c&H%s&}", hover_color) + local close_tag = string.format("{\\1c&H%s&}", base_color) + return before .. open_tag .. hovered .. close_tag .. after + 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 = sanitize_hover_ass_color(payload.colors and payload.colors.hover or nil, DEFAULT_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/.config/mpv/scripts/subminer/init.lua b/.config/mpv/scripts/subminer/init.lua new file mode 100644 index 0000000..0371a87 --- /dev/null +++ b/.config/mpv/scripts/subminer/init.lua @@ -0,0 +1,7 @@ +local M = {} + +function M.init() + require("bootstrap").init() +end + +return M diff --git a/.config/mpv/scripts/subminer/lifecycle.lua b/.config/mpv/scripts/subminer/lifecycle.lua new file mode 100644 index 0000000..c94e2d5 --- /dev/null +++ b/.config/mpv/scripts/subminer/lifecycle.lua @@ -0,0 +1,111 @@ +local M = {} + +function M.create(ctx) + local mp = ctx.mp + local opts = ctx.opts + local state = ctx.state + local options_helper = ctx.options_helper + local process = ctx.process + local aniskip = ctx.aniskip + local hover = ctx.hover + local subminer_log = ctx.log.subminer_log + local show_osd = ctx.log.show_osd + + local function schedule_aniskip_fetch(trigger_source, delay_seconds) + local delay = tonumber(delay_seconds) or 0 + mp.add_timeout(delay, function() + aniskip.fetch_aniskip_for_current_media(trigger_source) + end) + end + + local function resolve_auto_start_enabled() + local raw_auto_start = opts.auto_start + if raw_auto_start == nil then + raw_auto_start = opts.auto_start_overlay + end + if raw_auto_start == nil then + raw_auto_start = opts["auto-start"] + end + return options_helper.coerce_bool(raw_auto_start, false) + end + + local function on_file_loaded() + aniskip.clear_aniskip_state() + process.disarm_auto_play_ready_gate() + + local should_auto_start = resolve_auto_start_enabled() + if should_auto_start then + if not process.has_matching_mpv_ipc_socket(opts.socket_path) then + subminer_log( + "info", + "lifecycle", + "Skipping auto-start: input-ipc-server does not match configured socket_path" + ) + schedule_aniskip_fetch("file-loaded", 0) + return + end + + process.start_overlay({ + auto_start_trigger = true, + socket_path = opts.socket_path, + }) + -- Give the overlay process a moment to initialize before querying AniSkip. + schedule_aniskip_fetch("overlay-start", 0.8) + return + end + + schedule_aniskip_fetch("file-loaded", 0) + end + + local function on_shutdown() + aniskip.clear_aniskip_state() + hover.clear_hover_overlay() + process.disarm_auto_play_ready_gate() + if state.overlay_running then + subminer_log("info", "lifecycle", "mpv shutting down, hiding SubMiner overlay") + process.hide_visible_overlay() + end + end + + local function register_lifecycle_hooks() + mp.register_event("file-loaded", on_file_loaded) + mp.register_event("shutdown", on_shutdown) + mp.register_event("file-loaded", function() + hover.clear_hover_overlay() + end) + mp.register_event("end-file", function() + process.disarm_auto_play_ready_gate() + hover.clear_hover_overlay() + if state.overlay_running then + process.hide_visible_overlay() + end + end) + mp.register_event("shutdown", function() + hover.clear_hover_overlay() + end) + mp.register_event("end-file", function() + aniskip.clear_aniskip_state() + end) + mp.register_event("shutdown", function() + aniskip.clear_aniskip_state() + end) + mp.add_hook("on_unload", 10, function() + hover.clear_hover_overlay() + aniskip.clear_aniskip_state() + end) + mp.observe_property("sub-start", "native", function() + hover.clear_hover_overlay() + end) + mp.observe_property("time-pos", "number", function() + aniskip.update_intro_button_visibility() + end) + end + + return { + on_file_loaded = on_file_loaded, + on_shutdown = on_shutdown, + register_lifecycle_hooks = register_lifecycle_hooks, + } +end + +return M diff --git a/.config/mpv/scripts/subminer/log.lua b/.config/mpv/scripts/subminer/log.lua new file mode 100644 index 0000000..57edde8 --- /dev/null +++ b/.config/mpv/scripts/subminer/log.lua @@ -0,0 +1,67 @@ +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 + local payload = "SubMiner: " .. message + local sent = false + if type(mp.osd_message) == "function" then + sent = pcall(mp.osd_message, payload, 3) + end + if not sent and type(mp.commandv) == "function" then + pcall(mp.commandv, "show-text", payload, "3000") + end + 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/.config/mpv/scripts/subminer/main.lua b/.config/mpv/scripts/subminer/main.lua new file mode 100644 index 0000000..62ed65f --- /dev/null +++ b/.config/mpv/scripts/subminer/main.lua @@ -0,0 +1,30 @@ +local mp = require("mp") + +local function current_script_dir() + if type(mp.get_script_directory) == "function" then + local from_mpv = mp.get_script_directory() + if type(from_mpv) == "string" and from_mpv ~= "" then + return from_mpv + end + end + + local source = debug.getinfo(1, "S").source or "" + if source:sub(1, 1) == "@" then + local full = source:sub(2) + return full:match("^(.*)[/\\][^/\\]+$") or "." + end + return "." +end + +local script_dir = current_script_dir() +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 + +local init_module = assert(loadfile(script_dir .. "/init.lua"))() +if type(init_module) == "table" and type(init_module.init) == "function" then + init_module.init() +elseif type(init_module) == "function" then + init_module() +end diff --git a/.config/mpv/scripts/subminer/messages.lua b/.config/mpv/scripts/subminer/messages.lua new file mode 100644 index 0000000..44c5ade --- /dev/null +++ b/.config/mpv/scripts/subminer/messages.lua @@ -0,0 +1,57 @@ +local M = {} + +function M.create(ctx) + local mp = ctx.mp + local process = ctx.process + local aniskip = ctx.aniskip + local hover = ctx.hover + local ui = ctx.ui + + local function register_script_messages() + mp.register_script_message("subminer-start", function(...) + process.start_overlay_from_script_message(...) + end) + mp.register_script_message("subminer-stop", function() + process.stop_overlay() + end) + mp.register_script_message("subminer-toggle", function() + process.toggle_overlay() + end) + mp.register_script_message("subminer-menu", function() + ui.show_menu() + end) + mp.register_script_message("subminer-options", function() + process.open_options() + end) + mp.register_script_message("subminer-restart", function() + process.restart_overlay() + end) + mp.register_script_message("subminer-status", function() + process.check_status() + end) + mp.register_script_message("subminer-autoplay-ready", function() + process.notify_auto_play_ready() + end) + mp.register_script_message("subminer-aniskip-refresh", function() + aniskip.fetch_aniskip_for_current_media("script-message") + end) + mp.register_script_message("subminer-skip-intro", function() + aniskip.skip_intro_now() + end) + mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json) + hover.handle_hover_message(payload_json) + end) + mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json) + hover.handle_hover_message(payload_json) + end) + mp.register_script_message("subminer-stats-toggle", function() + mp.osd_message("Stats: press ` (backtick) in overlay", 3) + end) + end + + return { + register_script_messages = register_script_messages, + } +end + +return M diff --git a/.config/mpv/scripts/subminer/options.lua b/.config/mpv/scripts/subminer/options.lua new file mode 100644 index 0000000..f084314 --- /dev/null +++ b/.config/mpv/scripts/subminer/options.lua @@ -0,0 +1,72 @@ +local M = {} +local DEFAULT_ANISKIP_BUTTON_KEY = "TAB" + +local function normalize_socket_path_option(socket_path, default_socket_path) + if type(default_socket_path) ~= "string" then + return socket_path + end + + local trimmed_default = default_socket_path:match("^%s*(.-)%s*$") + local trimmed_socket = type(socket_path) == "string" and socket_path:match("^%s*(.-)%s*$") or socket_path + if trimmed_default ~= "\\\\.\\pipe\\subminer-socket" then + return trimmed_socket + end + if type(trimmed_socket) ~= "string" or trimmed_socket == "" then + return trimmed_default + end + if trimmed_socket == "/tmp/subminer-socket" or trimmed_socket == "\\tmp\\subminer-socket" then + return trimmed_default + end + if trimmed_socket == "\\\\.\\pipe\\tmp\\subminer-socket" then + return trimmed_default + end + return trimmed_socket +end + +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_visible_overlay = true, + auto_start_pause_until_ready = true, + auto_start_pause_until_ready_timeout_seconds = 15, + osd_messages = true, + log_level = "info", + aniskip_enabled = true, + aniskip_title = "", + aniskip_season = "", + aniskip_mal_id = "", + aniskip_episode = "", + aniskip_payload = "", + aniskip_show_button = true, + aniskip_button_text = "You can skip by pressing %s", + aniskip_button_key = DEFAULT_ANISKIP_BUTTON_KEY, + aniskip_button_duration = 3, + } + + options_lib.read_options(opts, "subminer") + opts.socket_path = normalize_socket_path_option(opts.socket_path, default_socket_path) + 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/.config/mpv/scripts/subminer/process.lua b/.config/mpv/scripts/subminer/process.lua new file mode 100644 index 0000000..50f72cf --- /dev/null +++ b/.config/mpv/scripts/subminer/process.lua @@ -0,0 +1,542 @@ +local M = {} + +local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 +local OVERLAY_START_MAX_ATTEMPTS = 6 +local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." +local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" +local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 + +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 run_control_command_async + + local function resolve_visible_overlay_startup() + local raw_visible_overlay = opts.auto_start_visible_overlay + if raw_visible_overlay == nil then + raw_visible_overlay = opts["auto-start-visible-overlay"] + end + return options_helper.coerce_bool(raw_visible_overlay, false) + end + + local function resolve_pause_until_ready() + local raw_pause_until_ready = opts.auto_start_pause_until_ready + if raw_pause_until_ready == nil then + raw_pause_until_ready = opts["auto-start-pause-until-ready"] + end + return options_helper.coerce_bool(raw_pause_until_ready, false) + end + + local function resolve_pause_until_ready_timeout_seconds() + local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds + if raw_timeout_seconds == nil then + raw_timeout_seconds = opts["auto-start-pause-until-ready-timeout-seconds"] + end + if type(raw_timeout_seconds) == "number" then + return raw_timeout_seconds + end + if type(raw_timeout_seconds) == "string" then + local parsed = tonumber(raw_timeout_seconds) + if parsed ~= nil then + return parsed + end + end + return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS + end + + local function normalize_socket_path(path) + if type(path) ~= "string" then + return nil + end + local trimmed = path:match("^%s*(.-)%s*$") + if trimmed == "" then + return nil + end + return trimmed + end + + local function has_matching_mpv_ipc_socket(target_socket_path) + local expected_socket = normalize_socket_path(target_socket_path or opts.socket_path) + local active_socket = normalize_socket_path(mp.get_property("input-ipc-server")) + if expected_socket == nil or active_socket == nil then + return false + end + return expected_socket == active_socket + 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 environment.detect_backend() + end + return selected + end + + local function clear_auto_play_ready_timeout() + local timeout = state.auto_play_ready_timeout + if timeout and timeout.kill then + timeout:kill() + end + state.auto_play_ready_timeout = nil + end + + local function clear_auto_play_ready_osd_timer() + local timer = state.auto_play_ready_osd_timer + if timer and timer.kill then + timer:kill() + end + state.auto_play_ready_osd_timer = nil + end + + local function disarm_auto_play_ready_gate(options) + local should_resume = options == nil or options.resume_playback ~= false + local was_armed = state.auto_play_ready_gate_armed + clear_auto_play_ready_timeout() + clear_auto_play_ready_osd_timer() + state.auto_play_ready_gate_armed = false + if was_armed and should_resume then + mp.set_property_native("pause", false) + end + end + + local function release_auto_play_ready_gate(reason) + if not state.auto_play_ready_gate_armed then + return + end + disarm_auto_play_ready_gate({ resume_playback = false }) + mp.set_property_native("pause", false) + show_osd(AUTO_PLAY_READY_READY_OSD) + subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) + end + + local function arm_auto_play_ready_gate() + if state.auto_play_ready_gate_armed then + clear_auto_play_ready_timeout() + clear_auto_play_ready_osd_timer() + end + state.auto_play_ready_gate_armed = true + mp.set_property_native("pause", true) + show_osd(AUTO_PLAY_READY_LOADING_OSD) + if type(mp.add_periodic_timer) == "function" then + state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function() + if state.auto_play_ready_gate_armed then + show_osd(AUTO_PLAY_READY_LOADING_OSD) + end + end) + end + subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal") + local timeout_seconds = resolve_pause_until_ready_timeout_seconds() + if timeout_seconds and timeout_seconds > 0 then + state.auto_play_ready_timeout = mp.add_timeout(timeout_seconds, function() + if not state.auto_play_ready_gate_armed then + return + end + subminer_log( + "warn", + "process", + "Startup readiness signal timed out; resuming playback to avoid stalled pause" + ) + release_auto_play_ready_gate("timeout") + end) + end + end + + local function notify_auto_play_ready() + release_auto_play_ready_gate("tokenization-ready") + if state.suppress_ready_overlay_restore then + return + end + if state.overlay_running and resolve_visible_overlay_startup() then + run_control_command_async("show-visible-overlay", { + socket_path = opts.socket_path, + }) + end + 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 + + if action == "start" 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) + + local should_show_visible = resolve_visible_overlay_startup() + if should_show_visible then + table.insert(args, "--show-visible-overlay") + else + table.insert(args, "--hide-visible-overlay") + end + + local texthooker_enabled = overrides.texthooker_enabled + if texthooker_enabled == nil then + texthooker_enabled = opts.texthooker_enabled + end + if texthooker_enabled then + table.insert(args, "--texthooker") + end + end + + return args + end + + run_control_command_async = function(action, overrides, callback) + local args = build_command_args(action, overrides) + subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) + mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + local ok = success and (result == nil or result.status == 0) + if callback then + callback(ok, result, error) + end + end) + 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 ensure_texthooker_running(callback) + if callback then + callback() + end + end + + local function start_overlay(overrides) + overrides = overrides or {} + if overrides.auto_start_trigger == true then + state.suppress_ready_overlay_restore = false + end + + 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 + if overrides.auto_start_trigger == true then + subminer_log("debug", "process", "Auto-start ignored because overlay is already running") + local socket_path = overrides.socket_path or opts.socket_path + local should_pause_until_ready = ( + resolve_visible_overlay_startup() + and resolve_pause_until_ready() + and has_matching_mpv_ipc_socket(socket_path) + ) + if should_pause_until_ready then + arm_auto_play_ready_gate() + else + disarm_auto_play_ready_gate() + end + local visibility_action = resolve_visible_overlay_startup() + and "show-visible-overlay" + or "hide-visible-overlay" + run_control_command_async(visibility_action, { + socket_path = socket_path, + log_level = overrides.log_level, + }) + return + end + subminer_log("info", "process", "Overlay already running") + show_osd("Already running") + return + end + + local texthooker_enabled = overrides.texthooker_enabled + if texthooker_enabled == nil then + texthooker_enabled = opts.texthooker_enabled + end + local socket_path = overrides.socket_path or opts.socket_path + local should_pause_until_ready = ( + overrides.auto_start_trigger == true + and resolve_visible_overlay_startup() + and resolve_pause_until_ready() + and has_matching_mpv_ipc_socket(socket_path) + ) + if should_pause_until_ready then + arm_auto_play_ready_gate() + else + disarm_auto_play_ready_gate() + end + + local function launch_overlay_with_retry(attempt) + local args = build_command_args("start", overrides) + if attempt == 1 then + subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " ")) + else + subminer_log( + "warn", + "process", + "Retrying overlay start (attempt " .. tostring(attempt) .. "): " .. table.concat(args, " ") + ) + end + + if attempt == 1 and not state.auto_play_ready_gate_armed then + show_osd("Starting...") + end + 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 + local reason = error or (result and result.stderr) or "unknown error" + if attempt < OVERLAY_START_MAX_ATTEMPTS then + mp.add_timeout(OVERLAY_START_RETRY_DELAY_SECONDS, function() + launch_overlay_with_retry(attempt + 1) + end) + return + end + + state.overlay_running = false + subminer_log("error", "process", "Overlay start failed after retries: " .. reason) + show_osd("Overlay start failed") + release_auto_play_ready_gate("overlay-start-failed") + return + end + + if overrides.auto_start_trigger == true then + local visibility_action = resolve_visible_overlay_startup() + and "show-visible-overlay" + or "hide-visible-overlay" + run_control_command_async(visibility_action, { + socket_path = socket_path, + log_level = overrides.log_level, + }) + end + + end) + end + + launch_overlay_with_retry(1) + if texthooker_enabled then + ensure_texthooker_running(function() end) + 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 + + run_control_command_async("stop", nil, function(ok, result) + if ok then + subminer_log("info", "process", "Overlay stopped") + else + subminer_log( + "warn", + "process", + "Stop command returned non-zero status: " .. tostring(result and result.status or "unknown") + ) + end + end) + + state.overlay_running = false + state.texthooker_running = false + disarm_auto_play_ready_gate() + show_osd("Stopped") + end + + local function hide_visible_overlay() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + return + end + state.suppress_ready_overlay_restore = true + + run_control_command_async("hide-visible-overlay", nil, function(ok, result) + if ok then + subminer_log("info", "process", "Visible overlay hidden") + else + subminer_log( + "warn", + "process", + "Hide-visible-overlay command returned non-zero status: " + .. tostring(result and result.status or "unknown") + ) + end + end) + + disarm_auto_play_ready_gate() + 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 + state.suppress_ready_overlay_restore = true + + run_control_command_async("toggle-visible-overlay", nil, function(ok) + if not ok then + subminer_log("warn", "process", "Toggle command failed") + show_osd("Toggle failed") + end + end) + end + + local function open_options() + if not binary.ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + run_control_command_async("settings", nil, function(ok) + if ok 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) + 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...") + + run_control_command_async("stop", nil, function() + state.overlay_running = false + state.texthooker_running = false + disarm_auto_play_ready_gate() + + 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) + + if opts.texthooker_enabled then + ensure_texthooker_running(function() end) + end + end) + end + + local function check_status() + if not binary.ensure_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 check_binary_available() + return binary.ensure_binary_available() + end + + return { + build_command_args = build_command_args, + has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, + run_control_command_async = run_control_command_async, + parse_start_script_message_overrides = parse_start_script_message_overrides, + ensure_texthooker_running = ensure_texthooker_running, + start_overlay = start_overlay, + start_overlay_from_script_message = start_overlay_from_script_message, + stop_overlay = stop_overlay, + hide_visible_overlay = hide_visible_overlay, + toggle_overlay = toggle_overlay, + open_options = open_options, + restart_overlay = restart_overlay, + check_status = check_status, + check_binary_available = check_binary_available, + notify_auto_play_ready = notify_auto_play_ready, + disarm_auto_play_ready_gate = disarm_auto_play_ready_gate, + } +end + +return M diff --git a/.config/mpv/scripts/subminer/state.lua b/.config/mpv/scripts/subminer/state.lua new file mode 100644 index 0000000..8814b0e --- /dev/null +++ b/.config/mpv/scripts/subminer/state.lua @@ -0,0 +1,39 @@ +local M = {} + +function M.new() + return { + overlay_running = false, + texthooker_running = false, + overlay_process = nil, + binary_available = false, + binary_path = nil, + detected_backend = nil, + 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, + payload = nil, + payload_source = nil, + found = false, + prompt_shown = false, + }, + auto_play_ready_gate_armed = false, + auto_play_ready_timeout = nil, + auto_play_ready_osd_timer = nil, + suppress_ready_overlay_restore = false, + } +end + +return M diff --git a/.config/mpv/scripts/subminer/ui.lua b/.config/mpv/scripts/subminer/ui.lua new file mode 100644 index 0000000..f4ff0e4 --- /dev/null +++ b/.config/mpv/scripts/subminer/ui.lua @@ -0,0 +1,114 @@ +local M = {} +local DEFAULT_ANISKIP_BUTTON_KEY = "TAB" +local LEGACY_ANISKIP_BUTTON_KEY = "y-k" + +function M.create(ctx) + local mp = ctx.mp + local input = ctx.input + local opts = ctx.opts + local process = ctx.process + local aniskip = ctx.aniskip + local subminer_log = ctx.log.subminer_log + local show_osd = ctx.log.show_osd + + local function ensure_binary_for_menu() + if process.check_binary_available() then + return true + end + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return false + end + + local function show_menu() + if not ensure_binary_for_menu() then + return + end + + local items = { + "Start overlay", + "Stop overlay", + "Toggle overlay", + "Open options", + "Restart overlay", + "Check status", + "Stats", + } + + local actions = { + function() + process.start_overlay() + end, + function() + process.stop_overlay() + end, + function() + process.toggle_overlay() + end, + function() + process.open_options() + end, + function() + process.restart_overlay() + end, + function() + process.check_status() + end, + function() + mp.commandv("script-message", "subminer-stats-toggle") + end, + } + + 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", function() + process.start_overlay() + end) + mp.add_key_binding("y-S", "subminer-stop", function() + process.stop_overlay() + end) + mp.add_key_binding("y-t", "subminer-toggle", function() + process.toggle_overlay() + end) + mp.add_key_binding("y-y", "subminer-menu", show_menu) + mp.add_key_binding("y-o", "subminer-options", function() + process.open_options() + end) + mp.add_key_binding("y-r", "subminer-restart", function() + process.restart_overlay() + end) + mp.add_key_binding("y-c", "subminer-status", function() + process.check_status() + end) + if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then + mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function() + aniskip.skip_intro_now() + end) + end + if + opts.aniskip_button_key ~= LEGACY_ANISKIP_BUTTON_KEY + and opts.aniskip_button_key ~= DEFAULT_ANISKIP_BUTTON_KEY + then + mp.add_key_binding(LEGACY_ANISKIP_BUTTON_KEY, "subminer-skip-intro-fallback", function() + aniskip.skip_intro_now() + end) + end + end + + return { + show_menu = show_menu, + register_keybindings = register_keybindings, + } +end + +return M diff --git a/.config/mpv/scripts/ytdl-preload.lua##os.Linux b/.config/mpv/scripts/ytdl-preload.lua##os.Linux deleted file mode 100644 index e4e5cd2..0000000 --- a/.config/mpv/scripts/ytdl-preload.lua##os.Linux +++ /dev/null @@ -1,405 +0,0 @@ ----------------------- --- #example ytdl_preload.conf --- # make sure lines do not have trailing whitespace --- # ytdl_opt has no sanity check and should be formatted exactly how it would appear in yt-dlp CLI, they are split into a key/value pair on whitespace --- # at least on Windows, do not escape '\' in temp, just us a single one for each divider - --- #temp=R:\ytdltest --- #ytdl_opt1=-r 50k --- #ytdl_opt2=-N 5 --- #ytdl_opt#=etc ----------------------- -local nextIndex -local caught = true --- local pop = false -local ytdl = "yt-dlp" -local utils = require 'mp.utils' - -local options = require 'mp.options' -local opts = { - temp = "/tmp/ytdl-preload", - ytdl_opt1 = "", - ytdl_opt2 = "", - ytdl_opt3 = "", - ytdl_opt4 = "", - ytdl_opt5 = "", - ytdl_opt6 = "", - ytdl_opt7 = "", - ytdl_opt8 = "", - ytdl_opt9 = "", -} -options.read_options(opts, "ytdl_preload") -local additionalOpts = {} -for k, v in pairs(opts) do - if k:find("ytdl_opt%d") and v ~= "" then - additionalOpts[k] = v - -- print("entry") - -- print(k .. v) - end -end -local cachePath = opts.temp - -local chapter_list = {} -local json = "" -local filesToDelete = {} - -local function exists(file) - local ok, err, code = os.rename(file, file) - if not ok then - if code == 13 then -- Permission denied, but it exists - return true - end - end - return ok, err -end -local function useNewLoadfile() - for _, c in pairs(mp.get_property_native("command-list")) do - if c["name"] == "loadfile" then - for _, a in pairs(c["args"]) do - if a["name"] == "index" then - return true - end - end - end - end -end ---from ytdl_hook -local function time_to_secs(time_string) - local ret - local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)") - if a ~= nil then - ret = (a * 3600 + b * 60 + c) - else - a, b = time_string:match("(%d%d?):(%d%d)") - if a ~= nil then - ret = (a * 60 + b) - end - end - return ret -end -local function extract_chapters(data, video_length) - local ret = {} - for line in data:gmatch("[^\r\n]+") do - local time = time_to_secs(line) - if time and (time < video_length) then - table.insert(ret, { time = time, title = line }) - end - end - table.sort(ret, function(a, b) return a.time < b.time end) - return ret -end -local function chapters() - if json.chapters then - for i = 1, #json.chapters do - local chapter = json.chapters[i] - local title = chapter.title or "" - if title == "" then - title = string.format('Chapter %02d', i) - end - table.insert(chapter_list, { time = chapter.start_time, title = title }) - end - elseif not (json.description == nil) and not (json.duration == nil) then - chapter_list = extract_chapters(json.description, json.duration) - end -end ---end ytdl_hook -local title = "" -local fVideo = "" -local fAudio = "" -local function load_files(dtitle, destination, audio, wait) - if wait then - if exists(destination .. ".mka") then - print("---wait success: found mka---") - audio = "audio-file=" .. destination .. '.mka,' - else - print("---could not find mka after wait, audio may be missing---") - end - end - -- if audio ~= "" then - -- table.insert(filesToDelete, destination .. ".mka") - -- end - -- table.insert(filesToDelete, destination .. ".mkv") - dtitle = dtitle:gsub("-" .. ("[%w_-]"):rep(11) .. "$", "") - dtitle = dtitle:gsub("^" .. ("%d"):rep(10) .. "%-", "") - if useNewLoadfile() then - mp.commandv("loadfile", destination .. ".mkv", "append", -1, - audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no') - else - mp.commandv("loadfile", destination .. ".mkv", "append", - audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no') --,sub-file="..destination..".en.vtt") --in case they are not set up to autoload - end - mp.commandv("playlist_move", mp.get_property("playlist-count") - 1, nextIndex) - mp.commandv("playlist_remove", nextIndex + 1) - caught = true - title = "" - -- pop = true -end - -local listenID = "" -local function listener(event) - if not caught and event.prefix == mp.get_script_name() and string.find(event.text, listenID) then - local destination = string.match(event.text, "%[download%] Destination: (.+).mkv") or - string.match(event.text, "%[download%] (.+).mkv has already been downloaded") - -- if destination then print("---"..cachePath) end; - if destination and string.find(destination, string.gsub(cachePath, '~/', '')) then - -- print(listenID) - mp.unregister_event(listener) - _, title = utils.split_path(destination) - local audio = "" - if fAudio == "" then - load_files(title, destination, audio, false) - else - if exists(destination .. ".mka") then - audio = "audio-file=" .. destination .. '.mka,' - load_files(title, destination, audio, false) - else - print("---expected mka but could not find it, waiting for 2 seconds---") - mp.add_timeout(2, function() - load_files(title, destination, audio, true) - end) - end - end - end - end -end - ---from ytdl_hook -mp.add_hook("on_preloaded", 10, function() - if string.find(mp.get_property("path"), cachePath) then - chapters() - if next(chapter_list) ~= nil then - mp.set_property_native("chapter-list", chapter_list) - chapter_list = {} - json = "" - end - end -end) ---end ytdl_hook -function dump(o) - if type(o) == 'table' then - local s = '{ ' - for k, v in pairs(o) do - if type(k) ~= 'number' then k = '"' .. k .. '"' end - s = s .. '[' .. k .. '] = ' .. dump(v) .. ',' - end - return s .. '} ' - else - return tostring(o) - end -end - -local function addOPTS(old) - for k, v in pairs(additionalOpts) do - -- print(k) - if string.find(v, "%s") then - for l, w in string.gmatch(v, "([-%w]+) (.+)") do - table.insert(old, l) - table.insert(old, w) - end - else - table.insert(old, v) - end - end - -- print(dump(old)) - return old -end - -local AudioDownloadHandle = {} -local VideoDownloadHandle = {} -local JsonDownloadHandle = {} -local function download_files(id, success, result, error) - if result.killed_by_us then - return - end - local jfile = cachePath .. "/" .. id .. ".json" - - local jfileIO = io.open(jfile, "w") - jfileIO:write(result.stdout) - jfileIO:close() - json = utils.parse_json(result.stdout) - -- print(dump(json)) - if json.requested_downloads[1].requested_formats ~= nil then - local args = { ytdl, "--no-continue", "-q", "-f", fAudio, "--restrict-filenames", "--no-playlist", "--no-part", - "-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mka", "--load-info-json", jfile } - args = addOPTS(args) - AudioDownloadHandle = mp.command_native_async({ - name = "subprocess", - args = args, - playback_only = false - }, function() - end) - else - fAudio = "" - fVideo = fVideo:gsub("bestvideo", "best") - fVideo = fVideo:gsub("bv", "best") - end - - local args = { ytdl, "--no-continue", "-f", fVideo .. '/best', "--restrict-filenames", "--no-playlist", - "--no-part", "-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mkv", "--load-info-json", jfile } - args = addOPTS(args) - VideoDownloadHandle = mp.command_native_async({ - name = "subprocess", - args = args, - playback_only = false - }, function() - end) -end - -local function DL() - local index = tonumber(mp.get_property("playlist-pos")) - if mp.get_property("playlist/" .. index .. "/filename"):find("/videos$") and mp.get_property("playlist/" .. index + 1 .. "/filename"):find("/shorts$") then - return - end - if tonumber(mp.get_property("playlist-pos-1")) > 0 and mp.get_property("playlist-pos-1") ~= mp.get_property("playlist-count") then - nextIndex = index + 1 - local nextFile = mp.get_property("playlist/" .. nextIndex .. "/filename") - if nextFile and caught and nextFile:find("://", 0, false) then - caught = false - mp.enable_messages("info") - mp.register_event("log-message", listener) - local ytFormat = mp.get_property("ytdl-format") - fVideo = string.match(ytFormat, '(.+)%+.+//?') or 'bestvideo' - fAudio = string.match(ytFormat, '.+%+(.+)//?') or 'bestaudio' - -- print("start"..nextFile) - listenID = tostring(os.time()) - local args = { ytdl, "--dump-single-json", "--no-simulate", "--skip-download", - "--restrict-filenames", - "--no-playlist", "--sub-lang", "en", "--write-sub", "--no-part", "-o", - cachePath .. "/" .. listenID .. "-%(title)s-%(id)s.%(ext)s", nextFile } - args = addOPTS(args) - -- print(dump(args)) - table.insert(filesToDelete, listenID) - JsonDownloadHandle = mp.command_native_async({ - name = "subprocess", - args = args, - capture_stdout = true, - capture_stderr = true, - playback_only = false - }, function(...) - download_files(listenID, ...) - end) - end - end -end - -local function clearCache() - -- print(pop) - - --if pop == true then - mp.abort_async_command(AudioDownloadHandle) - mp.abort_async_command(VideoDownloadHandle) - mp.abort_async_command(JsonDownloadHandle) - -- for k, v in pairs(filesToDelete) do - -- print("remove: " .. v) - -- os.remove(v) - -- end - local ftd = io.open(cachePath .. "/temp.files", "a") - for k, v in pairs(filesToDelete) do - ftd:write(v .. "\n") - if package.config:sub(1, 1) ~= '/' then - os.execute('del /Q /F "' .. cachePath .. "\\" .. v .. '*"') - else - os.execute('rm -f ' .. cachePath .. "/" .. v .. "*") - end - end - ftd:close() - print('clear') - mp.command("quit") - --end -end -mp.add_hook("on_unload", 50, function() - -- mp.abort_async_command(AudioDownloadHandle) - -- mp.abort_async_command(VideoDownloadHandle) - mp.abort_async_command(JsonDownloadHandle) - mp.unregister_event(listener) - caught = true - listenID = "resetYtdlPreloadListener" - -- print(listenID) -end) - -local skipInitial -mp.observe_property("playlist-count", "number", function() - if skipInitial then - DL() - else - skipInitial = true - end -end) - ---from ytdl_hook -local platform_is_windows = (package.config:sub(1, 1) == "\\") -local o = { - exclude = "", - try_ytdl_first = false, - use_manifests = false, - all_formats = false, - force_all_formats = true, - ytdl_path = "", -} -local paths_to_search = { "yt-dlp", "yt-dlp_x86", "youtube-dl" } ---local options = require 'mp.options' -options.read_options(o, "ytdl_hook") - -local separator = platform_is_windows and ";" or ":" -if o.ytdl_path:match("[^" .. separator .. "]") then - paths_to_search = {} - for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do - table.insert(paths_to_search, path) - end -end - -local function exec(args) - local ret = mp.command_native({ - name = "subprocess", - args = args, - capture_stdout = true, - capture_stderr = true - }) - return ret.status, ret.stdout, ret, ret.killed_by_us -end - -local msg = require 'mp.msg' -local command = {} -for _, path in pairs(paths_to_search) do - -- search for youtube-dl in mpv's config dir - local exesuf = platform_is_windows and ".exe" or "" - local ytdl_cmd = mp.find_config_file(path .. exesuf) - if ytdl_cmd then - msg.verbose("Found youtube-dl at: " .. ytdl_cmd) - ytdl = ytdl_cmd - break - else - msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories") - --search in PATH - command[1] = path - es, json, result, aborted = exec(command) - if result.error_string == "init" then - msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions") - else - msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH") - ytdl = path - break - end - end -end ---end ytdl_hook - -mp.register_event("start-file", DL) -mp.register_event("shutdown", clearCache) -local ftd = io.open(cachePath .. "/temp.files", "r") -while ftd ~= nil do - local line = ftd:read() - if line == nil or line == "" then - ftd:close() - io.open(cachePath .. "/temp.files", "w"):close() - break - end - -- print("DEL::"..line) - if package.config:sub(1, 1) ~= '/' then - os.execute('del /Q /F "' .. cachePath .. "\\" .. line .. '*" >nul 2>nul') - else - os.execute('rm -f ' .. cachePath .. "/" .. line .. "* &> /dev/null") - end -end - -