feat(plugin): add AniSkip intro skip flow with launcher metadata hints

This commit is contained in:
2026-02-22 02:14:37 -08:00
parent b3b55de4b9
commit f6e7dd496a
10 changed files with 1056 additions and 1 deletions

View File

@@ -40,6 +40,34 @@ osd_messages=yes
# Log level for plugin and SubMiner binary: debug, info, warn, error
log_level=info
# Enable AniSkip intro detection + markers.
aniskip_enabled=yes
# Force title (optional). Launcher fills this from guessit when available.
aniskip_title=
# Force season (optional). Launcher fills this from guessit when available.
aniskip_season=
# Force MAL id (optional). Leave blank for title lookup.
aniskip_mal_id=
# Force episode number (optional). Leave blank for filename/title detection.
aniskip_episode=
# Show intro skip OSD button while inside OP range.
aniskip_show_button=yes
# OSD text shown for intro skip action.
# `%s` is replaced by keybinding.
aniskip_button_text=You can skip by pressing %s
# Keybinding to execute intro skip when button is visible.
aniskip_button_key=y-k
# OSD hint duration in seconds (shown during first 3s of intro).
aniskip_button_duration=3
# MPV keybindings provided by plugin/subminer.lua:
# y-s start, y-S stop, y-t toggle visible overlay
# y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay

View File

@@ -65,6 +65,15 @@ local opts = {
auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden
osd_messages = true,
log_level = "info",
aniskip_enabled = true,
aniskip_title = "",
aniskip_season = "",
aniskip_mal_id = "",
aniskip_episode = "",
aniskip_show_button = true,
aniskip_button_text = "You can skip by pressing %s",
aniskip_button_key = "y-k",
aniskip_button_duration = 3,
}
options.read_options(opts, "subminer")
@@ -87,6 +96,15 @@ local state = {
clear_timer = nil,
last_hover_update_ts = 0,
},
aniskip = {
mal_id = nil,
title = nil,
episode = nil,
intro_start = nil,
intro_end = nil,
found = false,
prompt_shown = false,
},
}
local HOVER_MESSAGE_NAME = "subminer-hover-token"
@@ -138,6 +156,539 @@ local function show_osd(message)
end
end
local function url_encode(text)
if type(text) ~= "string" then
return ""
end
local encoded = text:gsub("\n", " ")
encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
return string.format("%%%02X", string.byte(char))
end)
return encoded:gsub(" ", "%%20")
end
local function run_json_curl(url)
local result = mp.command_native({
name = "subprocess",
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
return nil, result and result.stderr or "curl failed"
end
local parsed, parse_error = utils.parse_json(result.stdout)
if type(parsed) ~= "table" then
return nil, parse_error or "invalid json"
end
return parsed, nil
end
local function parse_episode_hint(text)
if type(text) ~= "string" or text == "" then
return nil
end
local patterns = {
"[Ss]%d+[Ee](%d+)",
"[Ee][Pp]?[%s%._%-]*(%d+)",
"[%s%._%-]+(%d+)[%s%._%-]+",
}
for _, pattern in ipairs(patterns) do
local token = text:match(pattern)
if token then
local episode = tonumber(token)
if episode and episode > 0 and episode < 10000 then
return episode
end
end
end
return nil
end
local function cleanup_title(raw)
if type(raw) ~= "string" then
return nil
end
local cleaned = raw
cleaned = cleaned:gsub("%b[]", " ")
cleaned = cleaned:gsub("%b()", " ")
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
cleaned = cleaned:gsub("[%._%-]+", " ")
cleaned = cleaned:gsub("%s+", " ")
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
if cleaned == "" then
return nil
end
return cleaned
end
local function extract_show_title_from_path(media_path)
if type(media_path) ~= "string" or media_path == "" then
return nil
end
local normalized = media_path:gsub("\\", "/")
local segments = {}
for segment in normalized:gmatch("[^/]+") do
segments[#segments + 1] = segment
end
for index = 1, #segments do
local segment = segments[index] or ""
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
local prior = segments[index - 1]
local cleaned = cleanup_title(prior or "")
if cleaned and cleaned ~= "" then
return cleaned
end
end
end
return nil
end
local function normalize_for_match(value)
if type(value) ~= "string" then
return ""
end
return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
end
local MATCH_STOPWORDS = {
the = true,
this = true,
that = true,
world = true,
animated = true,
series = true,
season = true,
no = true,
on = true,
["and"] = true,
}
local function tokenize_match_words(value)
local normalized = normalize_for_match(value)
local tokens = {}
for token in normalized:gmatch("%S+") do
if #token >= 3 and not MATCH_STOPWORDS[token] then
tokens[#tokens + 1] = token
end
end
return tokens
end
local function token_set(tokens)
local set = {}
for _, token in ipairs(tokens) do
set[token] = true
end
return set
end
local function title_overlap_score(expected_title, candidate_title)
local expected = normalize_for_match(expected_title)
local candidate = normalize_for_match(candidate_title)
if expected == "" or candidate == "" then
return 0
end
if candidate:find(expected, 1, true) then
return 120
end
local expected_tokens = tokenize_match_words(expected_title)
local candidate_tokens = token_set(tokenize_match_words(candidate_title))
if #expected_tokens == 0 then
return 0
end
local score = 0
local matched = 0
for _, token in ipairs(expected_tokens) do
if candidate_tokens[token] then
score = score + 30
matched = matched + 1
else
score = score - 20
end
end
if matched == 0 then
score = score - 80
end
local coverage = matched / #expected_tokens
if #expected_tokens >= 2 then
-- Require strong multi-token agreement to avoid false positives like "Shadow Skill".
if coverage >= 0.8 then
score = score + 30
elseif coverage >= 0.6 then
score = score + 10
else
score = score - 50
end
else
if coverage >= 1 then
score = score + 10
end
end
return score
end
local function has_any_sequel_marker(candidate_title)
local normalized = normalize_for_match(candidate_title)
if normalized == "" then
return false
end
local markers = {
"season 2",
"season 3",
"season 4",
"2nd season",
"3rd season",
"4th season",
"second season",
"third season",
"fourth season",
" ii ",
" iii ",
" iv ",
}
local padded = " " .. normalized .. " "
for _, marker in ipairs(markers) do
if padded:find(marker, 1, true) then
return true
end
end
return false
end
local function season_signal_score(requested_season, candidate_title)
local season = tonumber(requested_season)
if not season or season < 1 then
return 0
end
local normalized = " " .. normalize_for_match(candidate_title) .. " "
if normalized == " " then
return 0
end
if season == 1 then
return has_any_sequel_marker(candidate_title) and -60 or 20
end
local numeric_marker = string.format(" season %d ", season)
local ordinal_marker = string.format(" %dth season ", season)
local roman_markers = {
[2] = { " ii ", " second season ", " 2nd season " },
[3] = { " iii ", " third season ", " 3rd season " },
[4] = { " iv ", " fourth season ", " 4th season " },
[5] = { " v ", " fifth season ", " 5th season " },
}
if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
return 40
end
local aliases = roman_markers[season] or {}
for _, marker in ipairs(aliases) do
if normalized:find(marker, 1, true) then
return 40
end
end
if has_any_sequel_marker(candidate_title) then
return -20
end
return 5
end
local function resolve_title_and_episode()
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
local forced_season = tonumber(opts.aniskip_season)
local forced_episode = tonumber(opts.aniskip_episode)
local media_title = mp.get_property("media-title")
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
local path = mp.get_property("path") or ""
local path_show_title = extract_show_title_from_path(path)
local candidate_title = nil
if path_show_title and path_show_title ~= "" then
candidate_title = path_show_title
elseif forced_title ~= "" then
candidate_title = forced_title
else
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
end
local episode = forced_episode
or parse_episode_hint(media_title)
or parse_episode_hint(filename)
or parse_episode_hint(path)
or 1
return candidate_title, episode, forced_season
end
local function resolve_mal_id(title, season)
local forced_mal_id = tonumber(opts.aniskip_mal_id)
if forced_mal_id and forced_mal_id > 0 then
return forced_mal_id, "(forced-mal-id)"
end
if type(title) == "string" and title:match("^%d+$") then
local numeric = tonumber(title)
if numeric and numeric > 0 then
return numeric, title
end
end
if type(title) ~= "string" or title == "" then
return nil, nil
end
local lookup = title
if season and season > 1 then
lookup = string.format("%s Season %d", lookup, season)
end
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
local mal_json, mal_error = run_json_curl(mal_url)
if not mal_json then
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
return nil, lookup
end
local categories = mal_json.categories
if type(categories) ~= "table" then
return nil, lookup
end
for _, category in ipairs(categories) do
if type(category) == "table" and type(category.items) == "table" then
for _, item in ipairs(category.items) do
if type(item) == "table" and tonumber(item.id) then
subminer_log(
"info",
"aniskip",
string.format(
'MAL candidate selected (first result): id=%s name="%s" season_hint=%s',
tostring(item.id),
tostring(item.name or ""),
tostring(season or "-")
)
)
return tonumber(item.id), lookup
end
end
end
end
return nil, lookup
end
local function set_intro_chapters(intro_start, intro_end)
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
return
end
local current = mp.get_property_native("chapter-list")
local chapters = {}
if type(current) == "table" then
for _, chapter in ipairs(current) do
local title = type(chapter) == "table" and chapter.title or nil
if type(title) ~= "string" or not title:match("^AniSkip ") then
chapters[#chapters + 1] = chapter
end
end
end
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
table.sort(chapters, function(a, b)
local a_time = type(a) == "table" and tonumber(a.time) or 0
local b_time = type(b) == "table" and tonumber(b.time) or 0
return a_time < b_time
end)
mp.set_property_native("chapter-list", chapters)
end
local function remove_aniskip_chapters()
local current = mp.get_property_native("chapter-list")
if type(current) ~= "table" then
return
end
local chapters = {}
local changed = false
for _, chapter in ipairs(current) do
local title = type(chapter) == "table" and chapter.title or nil
if type(title) == "string" and title:match("^AniSkip ") then
changed = true
else
chapters[#chapters + 1] = chapter
end
end
if changed then
mp.set_property_native("chapter-list", chapters)
end
end
local function clear_aniskip_state()
state.aniskip.prompt_shown = false
state.aniskip.found = false
state.aniskip.mal_id = nil
state.aniskip.title = nil
state.aniskip.episode = nil
state.aniskip.intro_start = nil
state.aniskip.intro_end = nil
remove_aniskip_chapters()
end
local function skip_intro_now()
if not state.aniskip.found then
show_osd("Intro skip unavailable")
return
end
local intro_start = state.aniskip.intro_start
local intro_end = state.aniskip.intro_end
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
show_osd("Intro markers missing")
return
end
local now = mp.get_property_number("time-pos")
if type(now) ~= "number" then
show_osd("Skip unavailable")
return
end
local epsilon = 0.35
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
show_osd("Skip intro only during intro")
return
end
mp.set_property_number("time-pos", intro_end)
show_osd("Skipped intro")
end
local function update_intro_button_visibility()
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
return
end
local now = mp.get_property_number("time-pos")
if type(now) ~= "number" then
return
end
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
local intro_start = state.aniskip.intro_start or -1
local hint_window_end = intro_start + 3
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k"
local message = string.format(opts.aniskip_button_text, key)
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
state.aniskip.prompt_shown = true
end
end
local function apply_aniskip_payload(mal_id, title, episode, payload)
local results = payload and payload.results
if type(results) ~= "table" then
return false
end
for _, item in ipairs(results) do
if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
local intro_start = tonumber(item.interval.start_time)
local intro_end = tonumber(item.interval.end_time)
if intro_start and intro_end and intro_end > intro_start then
state.aniskip.found = true
state.aniskip.mal_id = mal_id
state.aniskip.title = title
state.aniskip.episode = episode
state.aniskip.intro_start = intro_start
state.aniskip.intro_end = intro_end
state.aniskip.prompt_shown = false
set_intro_chapters(intro_start, intro_end)
subminer_log(
"info",
"aniskip",
string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode)
)
return true
end
end
end
return false
end
local function fetch_aniskip_for_current_media()
clear_aniskip_state()
if not opts.aniskip_enabled then
return
end
local title, episode, season = resolve_title_and_episode()
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
local path_fallback = cleanup_title(mp.get_property("path") or "")
local lookup_titles = {}
local seen_titles = {}
local function push_lookup_title(candidate)
if type(candidate) ~= "string" then
return
end
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
if trimmed == "" then
return
end
local key = trimmed:lower()
if seen_titles[key] then
return
end
seen_titles[key] = true
lookup_titles[#lookup_titles + 1] = trimmed
end
push_lookup_title(title)
push_lookup_title(media_title_fallback)
push_lookup_title(filename_fallback)
push_lookup_title(path_fallback)
subminer_log(
"info",
"aniskip",
string.format(
'Query context: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
tostring(title or ""),
tostring(season or "-"),
tostring(episode or "-"),
tostring(opts.aniskip_title or ""),
tostring(opts.aniskip_season or "-"),
tostring(opts.aniskip_episode or "-"),
tostring(opts.aniskip_mal_id or "-"),
#lookup_titles
)
)
local mal_id, mal_lookup = nil, nil
for index, lookup_title in ipairs(lookup_titles) do
subminer_log(
"info",
"aniskip",
string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title)
)
local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season)
if attempt_mal_id then
mal_id = attempt_mal_id
mal_lookup = attempt_lookup
break
end
mal_lookup = attempt_lookup or mal_lookup
end
if not mal_id then
subminer_log(
"info",
"aniskip",
string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or ""))
)
return
end
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
subminer_log(
"info",
"aniskip",
string.format('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url)
)
local payload, fetch_error = run_json_curl(url)
if not payload then
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
return
end
if payload.found ~= true then
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
return
end
if not apply_aniskip_payload(mal_id, title, episode, payload) then
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
end
end
local function to_hex_color(input)
if type(input) ~= "string" then
return nil
@@ -1214,6 +1765,8 @@ check_status = function()
end
local function on_file_loaded()
clear_aniskip_state()
fetch_aniskip_for_current_media()
state.binary_path = find_binary()
if state.binary_path then
state.binary_available = true
@@ -1232,6 +1785,7 @@ local function on_file_loaded()
end
local function on_shutdown()
clear_aniskip_state()
clear_hover_overlay()
if (state.overlay_running or state.texthooker_running) and state.binary_available then
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
@@ -1251,6 +1805,12 @@ local function register_keybindings()
mp.add_key_binding("y-o", "subminer-options", open_options)
mp.add_key_binding("y-r", "subminer-restart", restart_overlay)
mp.add_key_binding("y-c", "subminer-status", check_status)
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", skip_intro_now)
end
if opts.aniskip_button_key ~= "y-k" then
mp.add_key_binding("y-k", "subminer-skip-intro-fallback", skip_intro_now)
end
end
local function register_script_messages()
@@ -1264,6 +1824,8 @@ local function register_script_messages()
mp.register_script_message("subminer-options", open_options)
mp.register_script_message("subminer-restart", restart_overlay)
mp.register_script_message("subminer-status", check_status)
mp.register_script_message("subminer-aniskip-refresh", fetch_aniskip_for_current_media)
mp.register_script_message("subminer-skip-intro", skip_intro_now)
mp.register_script_message(HOVER_MESSAGE_NAME, function(payload_json)
handle_hover_message(payload_json)
end)
@@ -1281,12 +1843,18 @@ local function init()
mp.register_event("file-loaded", clear_hover_overlay)
mp.register_event("end-file", clear_hover_overlay)
mp.register_event("shutdown", clear_hover_overlay)
mp.register_event("end-file", clear_aniskip_state)
mp.register_event("shutdown", clear_aniskip_state)
mp.add_hook("on_unload", 10, function()
clear_hover_overlay()
clear_aniskip_state()
end)
mp.observe_property("sub-start", "native", function()
clear_hover_overlay()
end)
mp.observe_property("time-pos", "number", function()
update_intro_button_visibility()
end)
subminer_log("info", "lifecycle", "SubMiner plugin loaded")
end