mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-04-09 04:19:24 -07:00
update
This commit is contained in:
1
.config/mpv/scripts/modernz.lua
Symbolic link
1
.config/mpv/scripts/modernz.lua
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../mpv-modules/ModernZ/modernz.lua
|
||||||
1
.config/mpv/scripts/mpv-youtube-queue
Symbolic link
1
.config/mpv/scripts/mpv-youtube-queue
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../projects/lua/mpv-youtube-queue/mpv-youtube-queue
|
||||||
9
.config/mpv/scripts/sponsorblock_shared/CLAUDE.md
Normal file
9
.config/mpv/scripts/sponsorblock_shared/CLAUDE.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<claude-mem-context>
|
||||||
|
# Recent Activity
|
||||||
|
|
||||||
|
### Feb 5, 2026
|
||||||
|
|
||||||
|
| ID | Time | T | Title | Read |
|
||||||
|
|----|------|---|-------|------|
|
||||||
|
| #142 | 8:49 PM | 🔵 | SponsorBlock Integration Found in MPV Configuration | ~233 |
|
||||||
|
</claude-mem-context>
|
||||||
758
.config/mpv/scripts/subminer/aniskip.lua
Normal file
758
.config/mpv/scripts/subminer/aniskip.lua
Normal file
@@ -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
|
||||||
150
.config/mpv/scripts/subminer/aniskip_match.lua
Normal file
150
.config/mpv/scripts/subminer/aniskip_match.lua
Normal file
@@ -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
|
||||||
301
.config/mpv/scripts/subminer/binary.lua
Normal file
301
.config/mpv/scripts/subminer/binary.lua
Normal file
@@ -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
|
||||||
80
.config/mpv/scripts/subminer/bootstrap.lua
Normal file
80
.config/mpv/scripts/subminer/bootstrap.lua
Normal file
@@ -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
|
||||||
210
.config/mpv/scripts/subminer/environment.lua
Normal file
210
.config/mpv/scripts/subminer/environment.lua
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
|
||||||
|
local detected_backend = nil
|
||||||
|
local app_running_cache_value = nil
|
||||||
|
local app_running_cache_time = nil
|
||||||
|
local app_running_check_inflight = false
|
||||||
|
local app_running_waiters = {}
|
||||||
|
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
||||||
|
|
||||||
|
local function is_windows()
|
||||||
|
return package.config:sub(1, 1) == "\\"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_macos()
|
||||||
|
local platform = mp.get_property("platform") or ""
|
||||||
|
if platform == "macos" or platform == "darwin" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local ostype = os.getenv("OSTYPE") or ""
|
||||||
|
return ostype:find("darwin") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function default_socket_path()
|
||||||
|
if is_windows() then
|
||||||
|
return "\\\\.\\pipe\\subminer-socket"
|
||||||
|
end
|
||||||
|
return "/tmp/subminer-socket"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_linux()
|
||||||
|
return not is_windows() and not is_macos()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function now_seconds()
|
||||||
|
if type(mp.get_time) == "function" then
|
||||||
|
local value = tonumber(mp.get_time())
|
||||||
|
if value then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_list_has_subminer(raw_process_list)
|
||||||
|
if type(raw_process_list) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local process_list = raw_process_list:lower()
|
||||||
|
for line in process_list:gmatch("[^\n]+") do
|
||||||
|
if is_windows() then
|
||||||
|
local image = line:match('^"([^"]+)","')
|
||||||
|
if not image then
|
||||||
|
image = line:match('^"([^"]+)"')
|
||||||
|
end
|
||||||
|
if not image then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
|
||||||
|
if not argv0 then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
local exe = argv0:match("([^/\\]+)$") or argv0
|
||||||
|
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_scan_command()
|
||||||
|
if is_windows() then
|
||||||
|
return { "tasklist", "/FO", "CSV", "/NH" }
|
||||||
|
end
|
||||||
|
return { "ps", "-A", "-o", "args=" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_process_running()
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
})
|
||||||
|
if not result or result.status ~= 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush_app_running_waiters(value)
|
||||||
|
local waiters = app_running_waiters
|
||||||
|
app_running_waiters = {}
|
||||||
|
for _, waiter in ipairs(waiters) do
|
||||||
|
waiter(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running_async(callback, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local force_refresh = opts.force_refresh == true
|
||||||
|
local now = now_seconds()
|
||||||
|
if not force_refresh and app_running_cache_value ~= nil and app_running_cache_time ~= nil then
|
||||||
|
if (now - app_running_cache_time) <= APP_RUNNING_CACHE_TTL_SECONDS then
|
||||||
|
callback(app_running_cache_value)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
app_running_waiters[#app_running_waiters + 1] = callback
|
||||||
|
if app_running_check_inflight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
app_running_check_inflight = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
}, function(success, result)
|
||||||
|
app_running_check_inflight = false
|
||||||
|
local running = false
|
||||||
|
if success and result and result.status == 0 then
|
||||||
|
running = process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
flush_app_running_waiters(running)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running()
|
||||||
|
local running = is_subminer_process_running()
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
return running
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_subminer_app_running_cache(running)
|
||||||
|
app_running_cache_value = running == true
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detect_backend()
|
||||||
|
if detected_backend then
|
||||||
|
return detected_backend
|
||||||
|
end
|
||||||
|
|
||||||
|
local backend = nil
|
||||||
|
local subminer_log = ctx.log and ctx.log.subminer_log or function() end
|
||||||
|
|
||||||
|
if is_macos() then
|
||||||
|
backend = "macos"
|
||||||
|
elseif is_windows() then
|
||||||
|
backend = nil
|
||||||
|
elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then
|
||||||
|
backend = "hyprland"
|
||||||
|
elseif os.getenv("SWAYSOCK") then
|
||||||
|
backend = "sway"
|
||||||
|
elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then
|
||||||
|
backend = "x11"
|
||||||
|
else
|
||||||
|
subminer_log("warn", "backend", "Could not detect window manager, falling back to x11")
|
||||||
|
backend = "x11"
|
||||||
|
end
|
||||||
|
|
||||||
|
detected_backend = backend
|
||||||
|
if backend then
|
||||||
|
subminer_log("info", "backend", "Detected backend: " .. backend)
|
||||||
|
else
|
||||||
|
subminer_log("info", "backend", "No backend detected")
|
||||||
|
end
|
||||||
|
return backend
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_windows = is_windows,
|
||||||
|
is_macos = is_macos,
|
||||||
|
is_linux = is_linux,
|
||||||
|
default_socket_path = default_socket_path,
|
||||||
|
is_subminer_process_running = is_subminer_process_running,
|
||||||
|
is_subminer_app_running = is_subminer_app_running,
|
||||||
|
is_subminer_app_running_async = is_subminer_app_running_async,
|
||||||
|
set_subminer_app_running_cache = set_subminer_app_running_cache,
|
||||||
|
detect_backend = detect_backend,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
431
.config/mpv/scripts/subminer/hover.lua
Normal file
431
.config/mpv/scripts/subminer/hover.lua
Normal file
@@ -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
|
||||||
7
.config/mpv/scripts/subminer/init.lua
Normal file
7
.config/mpv/scripts/subminer/init.lua
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.init()
|
||||||
|
require("bootstrap").init()
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
111
.config/mpv/scripts/subminer/lifecycle.lua
Normal file
111
.config/mpv/scripts/subminer/lifecycle.lua
Normal file
@@ -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
|
||||||
67
.config/mpv/scripts/subminer/log.lua
Normal file
67
.config/mpv/scripts/subminer/log.lua
Normal file
@@ -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
|
||||||
30
.config/mpv/scripts/subminer/main.lua
Normal file
30
.config/mpv/scripts/subminer/main.lua
Normal file
@@ -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
|
||||||
57
.config/mpv/scripts/subminer/messages.lua
Normal file
57
.config/mpv/scripts/subminer/messages.lua
Normal file
@@ -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
|
||||||
72
.config/mpv/scripts/subminer/options.lua
Normal file
72
.config/mpv/scripts/subminer/options.lua
Normal file
@@ -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
|
||||||
542
.config/mpv/scripts/subminer/process.lua
Normal file
542
.config/mpv/scripts/subminer/process.lua
Normal file
@@ -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
|
||||||
39
.config/mpv/scripts/subminer/state.lua
Normal file
39
.config/mpv/scripts/subminer/state.lua
Normal file
@@ -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
|
||||||
114
.config/mpv/scripts/subminer/ui.lua
Normal file
114
.config/mpv/scripts/subminer/ui.lua
Normal file
@@ -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
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user