--[[ mpv-skip-segment version 2.0 ================ A Lua script for mpv that detects and skips segments (intros, recaps, outros/credits, previews, and movie end-credits/post-credits) using crowdsourced data from TheIntroDB (api.theintrodb.org), IntroDB (introdb.app), and SkipDB (skipdb.tv). Features: - Triple provider support: TheIntroDB (v4 OpenAPI), IntroDB (OpenAPI), and SkipDB (openAPI) - Movie support: IntroDB end-credits (outro) and post-credits segments (is_movie=true) - Automatic provider fallback and intelligent segment merging - Fully non-blocking asynchronous HTTP requests using curl via mp.command_native_async - Interactive on-screen clickable button (Netflix-style) with cursor hover feedback - Dynamic mouse click capture (MBTN_LEFT) only while hovering over the button - Keybindable action (default: Tab) - Automatic media identification via file metadata tags, filename regex (SxxExx, movie years, IDs), and fallback title lookup - Configurable auto-skip with optional countdown - Fully configurable via ~~/script-opts/jumpskip.conf License: MIT --]] local mp = require 'mp' local utils = require 'mp.utils' local assdraw = require 'mp.assdraw' local options = require 'mp.options' local user_opts = { enabled = true, auto_skip = false, auto_skip_countdown = 0, autoskip_types = "", skip_button_timeout = 0, filter_directories = "", show_colored_segments = true, show_colored_intro_segments = true, show_colored_recap_segments = true, show_colored_outro_segments = true, show_colored_preview_segments = true, show_colored_post_credits_segments = true, skip_intro = true, skip_recap = true, skip_outro = true, skip_preview = false, skip_post_credits = false, theintrodb_intro_segment = true, theintrodb_recap_segment = true, theintrodb_outro_segment = true, theintrodb_preview_segment = true, introdb_intro_segment = true, introdb_recap_segment = true, introdb_outro_segment = true, introdb_preview_segment = true, introdb_post_credits_segment = true, skipdb_intro_segment = true, skipdb_recap_segment = true, skipdb_outro_segment = true, skipdb_preview_segment = true, mark_chapters = true, default_chapter_title = "Chapter", start_offset = 0.0, end_offset = 0.0, provider_priority_tvshow = "", provider_priority_movie = "", merge_providers = true, theintrodb_api_key = "", introdb_api_key = "", skipdb_api_key = "", tmdb_api_key = "", request_timeout = 8, keybind = "Tab", min_segment_duration = 3.0, enable_submission = true, submission_keybind = "Ctrl+Q", theintrodb_submit_enabled = true, introdb_submit_enabled = true, skipdb_submit_enabled = true, button_position = "bottom-right", button_margin_x = 60, button_margin_y = 80, button_width = 220, button_height = 56, button_font_size = 24, button_font = "mpv-osd", accent_color = "E75C6C", bg_color = "0A0A0A", text_color = "FFFFFF", hint_color = "AAAAAA", show_osd_message = true, osd_message_duration = 2.0, debug_mode = false, chapter_skip_enabled = true, chapter_skip_intro = true, chapter_skip_outro = true, chapter_skip_recap = true, chapter_skip_preview = true, chapter_skip_keywords_intro = "intro,introduction,opening,op,theme,title sequence", chapter_skip_keywords_outro = "ed,ending,outro,credits,end credits,closing", chapter_skip_keywords_recap = "recap,previously", chapter_skip_keywords_preview = "preview,next episode", chapter_skip_min_duration = 10.0, chapter_skip_max_duration = 300.0, chapter_skip_overlap_threshold = 0.5, } local function log_info(msg, ...) mp.msg.info(string.format(msg, ...)) end local function log_warn(msg, ...) mp.msg.warn(string.format(msg, ...)) end local function log_debug(msg, ...) if user_opts.debug_mode then mp.msg.info("[DEBUG] " .. string.format(msg, ...)) end end local current_bound_key = nil local current_submission_bound_key = nil local perform_skip local toggle_submission_overlay local render_submission_overlay local close_submission_overlay local function update_keybind() if current_bound_key then mp.remove_key_binding("jumpskip") end if user_opts.keybind and #user_opts.keybind > 0 then mp.add_key_binding(user_opts.keybind, "jumpskip", function() if perform_skip then perform_skip() end end) current_bound_key = user_opts.keybind end end local function update_submission_keybind() if current_submission_bound_key then mp.remove_key_binding("jumpskip_submit") end if user_opts.submission_keybind and #user_opts.submission_keybind > 0 then mp.add_key_binding(user_opts.submission_keybind, "jumpskip_submit", function() toggle_submission_overlay() end) current_submission_bound_key = user_opts.submission_keybind end end local VALID_SEGMENT_TYPES = { intro = true, recap = true, outro = true, preview = true, post_credits = true } local autoskip_type_set = {} local autoskip_types_active = false local VALID_PROVIDERS = { theintrodb = true, introdb = true, skipdb = true } local function parse_provider_priority(priority_str) local providers = {} if not priority_str or #priority_str == 0 then return providers end for token in string.gmatch(priority_str, "([^,%s]+)") do local p = token:match("^%s*(.-)%s*$"):lower() if #p > 0 then if VALID_PROVIDERS[p] then table.insert(providers, p) else log_warn("Ignoring unknown provider '%s' in priority list (valid: theintrodb, introdb, skipdb)", p) end end end return providers end local function validate_options() autoskip_type_set = {} autoskip_types_active = false for token in string.gmatch(tostring(user_opts.autoskip_types or ""), "([^,]+)") do local t = token:match("^%s*(.-)%s*$"):lower():gsub("%-", "_") if #t > 0 then if VALID_SEGMENT_TYPES[t] then autoskip_type_set[t] = true autoskip_types_active = true else log_warn("Ignoring invalid autoskip_types entry '%s' (valid values: intro, recap, outro, preview, post_credits)", t) end end end local timeout = tonumber(user_opts.skip_button_timeout) if timeout == nil then log_warn("Invalid skip_button_timeout value '%s'; using 0 (button stays visible for entire segment)", tostring(user_opts.skip_button_timeout)) timeout = 0 end if timeout < 0 then timeout = 0 end user_opts.skip_button_timeout = timeout end local function load_configuration() options.read_options(user_opts, "jumpskip") local script_dir = debug.getinfo(1, 'S').source:match("@?(.*[/\\])") local candidates = { script_dir and (script_dir .. "jumpskip.conf") or nil, script_dir and (script_dir .. "../jumpskip.conf") or nil, script_dir and (script_dir .. "../script-opts/jumpskip.conf") or nil, script_dir and (script_dir .. "../scripts-opts/jumpskip.conf") or nil, "./jumpskip.conf", "./script-opts/jumpskip.conf", } local loaded_from = nil for _, path in ipairs(candidates) do if path then local f = io.open(path, "r") if f then for line in f:lines() do line = line:match("^%s*(.-)%s*$") if line ~= "" and not line:match("^[#;]") then local k, v = line:match("^([%w_%-]+)%s*=%s*(.*)$") if k and v then k = k:gsub("%-", "_") if user_opts[k] ~= nil then if type(user_opts[k]) == "boolean" then local low = v:lower() user_opts[k] = (low == "yes" or low == "true" or low == "1" or low == "on") elseif type(user_opts[k]) == "number" then local num = tonumber(v) if num then user_opts[k] = num end else user_opts[k] = v end end end end end f:close() loaded_from = path break end end end validate_options() update_keybind() update_submission_keybind() log_info("Configuration active (keybind: %s, auto_skip: %s, tv_priority: %s, movie_priority: %s)%s", tostring(user_opts.keybind), tostring(user_opts.auto_skip), tostring(user_opts.provider_priority_tvshow), tostring(user_opts.provider_priority_movie), loaded_from and (" [source: " .. loaded_from .. "]") or "") end local function round(v) return math.floor(v + 0.5) end local function clamp(val, min, max) if val < min then return min end if val > max then return max end return val end local function opacity_to_alpha(opacity) local clamped = clamp(opacity, 0.0, 1.0) return 255 - round(255 * clamped) end local function url_encode(str) if not str then return "" end return string.gsub(str, "([^%w%-%_%.%~])", function(c) return string.format("%%%02X", string.byte(c)) end) end local TitleParser = {} function TitleParser.max_release_year() local cur_year = tonumber(os.date("%Y")) or 2026 return cur_year + 1 end local SPECIAL_TERMS = { "rifftrax", "commentary", "reaction", "re edit", "re-edit", "behind the scenes", "featurette", "making of", "interview", "deleted scenes", "bonus", "short", "documentary" } function TitleParser.contains_special(str) if not str then return false end local low = str:lower() for _, term in ipairs(SPECIAL_TERMS) do local pat = "[%s%p]" .. term:gsub("%-", "%%-") .. "[%s%p]" local prefixed = " " .. low .. " " if prefixed:find(pat) or low:find("^" .. term:gsub("%-", "%%-") .. "[%s%p]") or low:find("[%s%p]" .. term:gsub("%-", "%%-") .. "$") or low == term then return true, term end end return false, nil end function TitleParser.clean_title(raw) if not raw then return "" end local s = raw -- Strip release groups: s = s:gsub("^%s*%b[]%s*", "") local leading_paren = s:match("^%s*%((.-)%)%s*[_%-]?%s*") if leading_paren and not leading_paren:match("^%d%d%d%d$") and not leading_paren:match("^%d+$") then s = s:gsub("^%s*%b()%s*[_%-]?%s*", "") end -- Strip bracketed and parenthesized hashes: s = s:gsub("%[[0-9a-fA-F]+%]", " ") s = s:gsub("%([0-9a-fA-F]+%)", " ") -- Replace dots, underscores, hyphens with spaces: s = s:gsub("[%._%-]", " ") -- Strip resolution tags: s = s:gsub("%f[%w][12]%d%d%d[pi]%f[%W]", " ") s = s:gsub("%f[%w][475]%d%d[pi]%f[%W]", " ") s = s:gsub("%f[%w][48][kK]%f[%W]", " ") s = s:gsub("%f[%w][uU][hH][dD]%f[%W]", " ") s = s:gsub("%f[%w][fF][hH][dD]%f[%W]", " ") -- Strip video codecs: s = s:gsub("%f[%w][xXhH]26[45]%f[%W]", " ") s = s:gsub("%f[%w][hH][eE][vV][cC]%f[%W]", " ") s = s:gsub("%f[%w][aA][vV][cC]%f[%W]", " ") s = s:gsub("%f[%w][aA][vV]1%f[%W]", " ") s = s:gsub("%f[%w][xX][vV][iI][dD]%f[%W]", " ") s = s:gsub("%f[%w][dD][iI][vV][xX]%f[%W]", " ") s = s:gsub("%f[%w]10[bB][iI][tT]%f[%W]", " ") s = s:gsub("%f[%w]8[bB][iI][tT]%f[%W]", " ") -- Strip sources: s = s:gsub("%f[%w][bB][lL][uU]%s*[rR][aA][yY]%f[%W]", " ") s = s:gsub("%f[%w][bB][dD][rR][iI][pP]%f[%W]", " ") s = s:gsub("%f[%w][bB][rR][rR][iI][pP]%f[%W]", " ") s = s:gsub("%f[%w][wW][eE][bB]%s*[dD][lL]%f[%W]", " ") s = s:gsub("%f[%w][wW][eE][bB][dD][lL]%f[%W]", " ") s = s:gsub("%f[%w][wW][eE][bB]%s*[rR][iI][pP]%f[%W]", " ") s = s:gsub("%f[%w][wW][eE][bB][rR][iI][pP]%f[%W]", " ") s = s:gsub("%f[%w][hH][dD][tT][vV]%f[%W]", " ") s = s:gsub("%f[%w][pP][dD][tT][vV]%f[%W]", " ") s = s:gsub("%f[%w][dD][vV][dD][rR][iI][pP]%f[%W]", " ") s = s:gsub("%f[%w][dD][vV][dD]%f[%W]", " ") s = s:gsub("%f[%w][rR][eE][mM][uU][xX]%f[%W]", " ") s = s:gsub("%f[%w][iI][mM][aA][xX]%f[%W]", " ") -- Strip audio tags: s = s:gsub("%f[%w][dD][tT][sS]%s*[hH][dD]%f[%W]", " ") s = s:gsub("%f[%w][dD][tT][sS]%f[%W]", " ") s = s:gsub("%f[%w][tT][rR][uU][eE][hH][dD]%f[%W]", " ") s = s:gsub("%f[%w][aA][tT][mM][oO][sS]%f[%W]", " ") s = s:gsub("%f[%w][dD][dD]%+?%s*[572]%s*1%f[%W]", " ") s = s:gsub("%f[%w][dD][dD]%+?%s*2%s*0%f[%W]", " ") s = s:gsub("%f[%w][aA][cC]3%f[%W]", " ") s = s:gsub("%f[%w][eE][aA][cC]3%f[%W]", " ") s = s:gsub("%f[%w][aA][aA][cC]%f[%W]", " ") s = s:gsub("%f[%w][fF][lL][aA][cC]%f[%W]", " ") -- Strip release tags: s = s:gsub("%f[%w][pP][rR][oO][pP][eE][rR]%f[%W]", " ") s = s:gsub("%f[%w][rR][eE][pP][aA][cC][kK]%f[%W]", " ") s = s:gsub("%f[%w][rR][eE][rR][iI][pP]%f[%W]", " ") s = s:gsub("%f[%w][eE][xX][tT][eE][nN][dD][eE][dD]%f[%W]", " ") s = s:gsub("%f[%w][uU][nN][rR][aA][tT][eE][dD]%f[%W]", " ") s = s:gsub("%f[%w][rR][eE][mM][aA][sS][tT][eE][rR][eE][dD]%f[%W]", " ") s = s:gsub("%f[%w][eE][xX][tT][rR][aA]%f[%W]", " ") -- Strip filesize tags: s = s:gsub("%d+%s*[mM][bB]%f[%W]", " ") s = s:gsub("%d+%s*[gG][bB]%f[%W]", " ") -- Strip empty parentheses and brackets: s = s:gsub("%b[]", " ") s = s:gsub("%b()", " ") -- Clean whitespace: s = s:gsub("%s+", " ") return s:match("^%s*(.-)%s*$") or "" end function TitleParser.parse_season_episode(str) if not str or #str == 0 then return nil, nil, nil, false end local s_title, s, e local season_missing = false s_title, s, e = str:match("^(.-)[%._%-%s%([]+[sS](%d%d?)[%._%-%s%]]*[eE](%d%d?%d?)") if not s_title then s_title, s, e = str:match("^(.-)[%._%-%s%([]+(%d%d?)[xX](%d%d?%d?)") end if not s_title then s_title, s, e = str:match("^(.-)[%._%-%s%([]+[sS]eason[%._%-%s]*(%d%d?)[%._%-%s%]]*[eE]pisode[%._%-%s]*(%d%d?%d?)") end if not s_title then local st, ep = str:match("^(.-)[%._%-%s%([]+[eE]pisode[%._%-%s]*(%d%d?%d?)") if st and ep then s_title, s, e = st, "1", ep season_missing = true end end if not s_title then local st, ep = str:match("^(.-)[%._%-%s%([]+[eE][pP][%._%-%s]*(%d%d?%d?)") if st and ep then s_title, s, e = st, "1", ep season_missing = true end end if not s_title then local st, ep = str:match("^(.-)[%._%-%s%([]+[eE](%d%d?%d?)$") if not st then st, ep = str:match("^(.-)[%._%-%s%([]+[eE](%d%d?%d?)[%._%-%s%)%]]") end if st and ep then s_title, s, e = st, "1", ep season_missing = true end end if not s_title then local st, ep = str:match("^(.-)[%s%._%-]+[eE]pisode[%s%._%-]+(%d%d?%d?)[%s%._%-%)%]]*$") if not st then st, ep = str:match("^(.-)[%s%._%-]+[eE]pisode[%s%._%-]+(%d%d?%d?)") end if not st then st, ep = str:match("^(.-)[%s%._%-]+[eE][pP][%s%._%-]+(%d%d?%d?)[%s%._%-%)%]]*$") end if not st then st, ep = str:match("^(.-)[%s%._%-]+[eE][pP][%s%._%-]+(%d%d?%d?)") end if st and ep then local num = tonumber(ep) if num and num > 0 and num <= 999 then st = st:gsub("[%s%._%-]+[eE]pisode[%s%._%-]*$", "") st = st:gsub("[%s%._%-]+[eE][pP][%s%._%-]*$", "") s_title, s, e = st, "1", ep season_missing = true end end end if not s_title then local st, ep = str:match("^(.-)[%s%._%-]+%-[%s%._%-]+(%d+)[vV]?%d*[%s%._%-%(%]]") if not st then st, ep = str:match("^(.-)[%s%._%-]+%-[%s%._%-]+(%d+)[vV]?%d*$") end if st and ep then local num = tonumber(ep) local max_year = TitleParser.max_release_year() if num and num > 0 and num <= 2000 and not (num >= 1900 and num <= max_year) then local sea_title, sea_num = st:match("^(.-)[%s%._%-]+[sS](%d%d?)$") if not sea_title then sea_title, sea_num = st:match("^(.-)[%s%._%-]+[sS]eason[%s%._%-]*(%d%d?)$") end if sea_title and sea_num then s_title = sea_title s = sea_num e = ep season_missing = false else s_title = st s = "1" e = ep season_missing = true end end end end if not s_title then local st, ep = str:match("^(.-)[%s%._%-]+(0%d%d?)[vV]?%d*[%s%._%-%(%]]") if not st then st, ep = str:match("^(.-)[%s%._%-]+(0%d%d?)[vV]?%d*$") end if st and ep then local num = tonumber(ep) if num and num > 0 and num <= 999 then local sea_title, sea_num = st:match("^(.-)[%s%._%-]+[sS](%d%d?)$") if not sea_title then sea_title, sea_num = st:match("^(.-)[%s%._%-]+[sS]eason[%s%._%-]*(%d%d?)$") end if sea_title and sea_num then s_title = sea_title s = sea_num e = ep season_missing = false else s_title = st s = "1" e = ep season_missing = true end end end end if s_title and #s_title > 1 and s and e then local max_year = TitleParser.max_release_year() local parsed_year = nil local raw_title = s_title local y_str = s_title:match("[%._%-%s%(%[]+(%d%d%d%d)[%._%-%s%)%]]*$") local y_num = tonumber(y_str) if y_num and y_num >= 1900 and y_num <= max_year then local base = s_title:match("^(.-)[%._%-%s%(%[]+" .. y_str .. "[%._%-%s%)%]]*$") if base and base:match("%w") then s_title = base parsed_year = y_num end end local cleaned_title = TitleParser.clean_title(s_title) if not parsed_year and cleaned_title and #cleaned_title > 0 then local cy_str = cleaned_title:match("%s+(%d%d%d%d)%s*$") local cy_num = tonumber(cy_str) if cy_num and cy_num >= 1900 and cy_num <= max_year then local c_base = cleaned_title:match("^(.-)%s+" .. cy_str .. "%s*$") if c_base and c_base:match("%w") then cleaned_title = c_base parsed_year = cy_num end end end local title_with_year = TitleParser.clean_title(raw_title) return cleaned_title, tonumber(s), tonumber(e), season_missing, parsed_year, title_with_year end return nil, nil, nil, false, nil, nil end function TitleParser.canonical_title(str) if not str then return "" end local s = str:lower() s = s:gsub("part%s+one%f[%W]", "part 1") s = s:gsub("part%s+two%f[%W]", "part 2") s = s:gsub("part%s+three%f[%W]", "part 3") s = s:gsub("part%s+four%f[%W]", "part 4") s = s:gsub("part%s+i%f[%W]", "part 1") s = s:gsub("part%s+ii%f[%W]", "part 2") s = s:gsub("part%s+iii%f[%W]", "part 3") s = s:gsub("part%s+iv%f[%W]", "part 4") s = s:gsub("%s*&%s*", " and ") s = s:gsub("%s*%+%s*", " plus ") s = s:gsub("%s*@%s*", " at ") s = s:gsub("%f[%w]versus%f[%W]", "vs") s = s:gsub("%s*vs%.%s*", " vs ") s = s:gsub("[%p]", " ") s = s:gsub("%s+", " ") return s:match("^%s*(.-)%s*$") or "" end function TitleParser.canonical_sequel(canon_str) if not canon_str then return "" end local s = canon_str:gsub("part%s+(%d+)", "%1") s = s:gsub("%s+", " ") return s:match("^%s*(.-)%s*$") or "" end function TitleParser.validate_series_seasons(meta_videos, media_info) if not media_info.is_tv then return true end if not media_info.season_missing then return true end local seasons_set = {} for _, v in ipairs(meta_videos or {}) do local s = tonumber(v.season) if s and s > 0 then seasons_set[s] = true end end local season_count = 0 for _ in pairs(seasons_set) do season_count = season_count + 1 end if season_count > 1 then return false, season_count end media_info.season = 1 media_info.season_missing = false return true, season_count end function TitleParser.score_movie_candidate(candidate, query_title, target_year, all_candidates) if not candidate or not candidate.name then return -99999 end local cand_name = candidate.name local cand_year = tonumber(candidate.year) or tonumber(candidate.releaseInfo) if not cand_year and cand_name then local parens = cand_name:match("%b()") local y_match = parens and parens:match("(%d%d%d%d)") or cand_name:match("(%d%d%d%d)") cand_year = tonumber(y_match) end local query_has_special = TitleParser.contains_special(query_title) local cand_has_special = TitleParser.contains_special(cand_name) local cand_is_doc_or_short = false if candidate.genres then for _, g in ipairs(candidate.genres) do local gl = g:lower() if gl == "short" or gl == "documentary" then cand_is_doc_or_short = true end end end if not query_has_special and (cand_has_special or cand_is_doc_or_short) then return -99999 end local has_year_compatible = false if target_year and all_candidates then for _, other in ipairs(all_candidates) do local oy = tonumber(other.year) or tonumber(other.releaseInfo) if not oy and other.name then local parens = other.name:match("%b()") local ym = parens and parens:match("(%d%d%d%d)") or other.name:match("(%d%d%d%d)") oy = tonumber(ym) end if oy and math.abs(oy - target_year) <= 1 then local o_special = TitleParser.contains_special(other.name) if query_has_special or not o_special then has_year_compatible = true break end end end end if target_year then if cand_year then local diff = math.abs(cand_year - target_year) if has_year_compatible and diff > 1 then return -99999 end if diff > 1 then return -99999 end end end local q_canon = TitleParser.canonical_title(query_title) local c_canon = TitleParser.canonical_title(cand_name) local q_seq = TitleParser.canonical_sequel(q_canon) local c_seq = TitleParser.canonical_sequel(c_canon) local function strip_articles(s) return (s or ""):gsub("^the%s+", ""):gsub("^a%s+", ""):gsub("^an%s+", "") end local q_clean = strip_articles(q_canon) local c_clean = strip_articles(c_canon) for c_num in c_seq:gmatch("%f[%w](%d+)%f[%W]") do local n = tonumber(c_num) if n and n > 1 and n < 1900 then local q_has_num = q_seq:find("%f[%w]" .. c_num .. "%f[%W]") ~= nil if not q_has_num then return -99999 end end end if target_year then c_canon = c_canon:gsub("%s+" .. tostring(target_year) .. "$", "") c_clean = c_clean:gsub("%s+" .. tostring(target_year) .. "$", "") c_seq = c_seq:gsub("%s+" .. tostring(target_year) .. "$", "") end local score = 0 if q_canon == c_canon or q_seq == c_seq or q_clean == c_clean then score = 1000 elseif c_canon:find("^" .. q_canon .. "%s+part%s+%d+") or c_clean:find("^" .. q_clean .. "%s+part%s+%d+") then if target_year and cand_year and math.abs(cand_year - target_year) <= 1 then score = 950 else score = 400 end elseif q_canon:find("^" .. c_canon .. "%s+part%s+%d+") or q_clean:find("^" .. c_clean .. "%s+part%s+%d+") then score = 800 else local q_words = {} for w in q_canon:gmatch("%w+") do if w ~= "the" and w ~= "a" and w ~= "an" then q_words[w] = true end end local c_words = {} local c_extra_words = 0 for w in c_canon:gmatch("%w+") do if w ~= "the" and w ~= "a" and w ~= "an" then c_words[w] = true if not q_words[w] and w ~= "part" and not tonumber(w) then c_extra_words = c_extra_words + 1 end end end local match_count = 0 local total_q = 0 for w in pairs(q_words) do total_q = total_q + 1 if c_words[w] then match_count = match_count + 1 end end if total_q > 0 and match_count == total_q and c_extra_words == 0 then score = 800 else score = 0 end end if score > 0 and target_year and cand_year then local diff = math.abs(cand_year - target_year) if diff == 0 then score = score + 200 elseif diff == 1 then score = score + 100 end end if query_has_special and cand_has_special then score = score + 500 end return score end function TitleParser.score_series_candidate(candidate, query_title, target_year_or_candidates, all_candidates) if not candidate or not candidate.name then return -99999 end local target_year = nil if type(target_year_or_candidates) == "number" then target_year = target_year_or_candidates elseif type(target_year_or_candidates) == "table" and not all_candidates then all_candidates = target_year_or_candidates end local q_canon = TitleParser.canonical_title(query_title) local c_canon = TitleParser.canonical_title(candidate.name) local function strip_articles(s) return (s or ""):gsub("^the%s+", ""):gsub("^a%s+", ""):gsub("^an%s+", "") end local q_clean = strip_articles(q_canon) local c_clean = strip_articles(c_canon) local cand_year = tonumber(candidate.year) if not cand_year and candidate.releaseInfo then cand_year = tonumber(tostring(candidate.releaseInfo):match("%d%d%d%d")) end if not cand_year and candidate.name then local parens = candidate.name:match("%b()") local y_match = parens and parens:match("(%d%d%d%d)") or candidate.name:match("(%d%d%d%d)") cand_year = tonumber(y_match) end local has_year_compatible = false if target_year and all_candidates then for _, other in ipairs(all_candidates) do local oy = tonumber(other.year) if not oy and other.releaseInfo then oy = tonumber(tostring(other.releaseInfo):match("%d%d%d%d")) end if not oy and other.name then local parens = other.name:match("%b()") local ym = parens and parens:match("(%d%d%d%d)") or other.name:match("(%d%d%d%d)") oy = tonumber(ym) end if oy and math.abs(oy - target_year) <= 1 then has_year_compatible = true break end end end local score = 0 if q_canon == c_canon or q_clean == c_clean then score = 1000 else local q_words = {} for w in q_clean:gmatch("%w+") do q_words[w] = true end local c_words = {} local c_extra = 0 for w in c_clean:gmatch("%w+") do c_words[w] = true if not q_words[w] then c_extra = c_extra + 1 end end local match_count = 0 local total_q = 0 for w in pairs(q_words) do total_q = total_q + 1 if c_words[w] then match_count = match_count + 1 end end local word_match_bonus = match_count * 10 if total_q > 0 and match_count == total_q and c_extra == 0 then score = 800 + word_match_bonus elseif total_q > 0 and match_count == total_q and c_extra <= 1 then score = 400 + word_match_bonus elseif match_count > 0 then score = word_match_bonus else score = 0 end end if target_year and cand_year then local diff = math.abs(cand_year - target_year) if diff <= 1 then score = score + 200 elseif has_year_compatible and diff > 1 then score = score - 500 end end return score end function TitleParser.extract_media_metadata(path, filename, raw_media_title, metadata) path = path or "" filename = filename or "" raw_media_title = raw_media_title or "" metadata = metadata or {} local info = { is_tv = false, title = nil, season = nil, episode = nil, year = nil, imdb_id = nil, tmdb_id = nil, tvdb_id = nil, season_missing = false, year_ambiguous = false, } for key, val in pairs(metadata) do local k = string.lower(tostring(key)) local v = tostring(val) if k == "imdb" or k == "imdb_id" or k == "imdbid" then local id = v:match("tt%d%d%d%d%d%d%d%d?") if id then info.imdb_id = id end elseif k == "tmdb" or k == "tmdb_id" or k == "tmdbid" then local id = tonumber(v:match("%d+")) if id then info.tmdb_id = id end elseif k == "tvdb" or k == "tvdb_id" or k == "tvdbid" then local id = tonumber(v:match("%d+")) if id then info.tvdb_id = id end elseif k == "season_number" or k == "season" then info.season = tonumber(v) elseif k == "episode_id" or k == "episode_sort" or k == "episode_number" or k == "episode" then info.episode = tonumber(v) end end local target_text = filename .. " " .. path .. " " .. raw_media_title if not info.imdb_id then local id = target_text:match("tt%d%d%d%d%d%d%d%d?") if id then info.imdb_id = id end end if not info.tmdb_id then local id_str = target_text:match("tmdb%-(%d+)") or target_text:match("tmdbid%-(%d+)") if id_str then info.tmdb_id = tonumber(id_str) end end if not info.tvdb_id then local id_str = target_text:match("tvdb%-(%d+)") or target_text:match("tvdbid%-(%d+)") if id_str then info.tvdb_id = tonumber(id_str) end end local function is_generic_stream_filename(fn) if not fn then return false end local lower_fn = fn:lower() local generic_patterns = { "^index%.m3u8$", "^master%.m3u8$", "^playlist%.m3u8$", "^stream%.m3u8$", "^video%.m3u8$", "^audio%.m3u8$", "^media%.m3u8$", "^live%.m3u8$", "^manifest%.mpd$", "^manifest%.m3u8$", "^seg%-%d+%.ts$", "^segment%-%d+%.ts$", "^chunk%-%d+%.ts$", "^init%.mp4$", "^init%.m4s$", } for _, pattern in ipairs(generic_patterns) do if lower_fn:match(pattern) then return true end end return false end local fn = filename local use_filename = fn and #fn > 0 and not fn:match("^av://") and not fn:match("^lavfi:") and not is_generic_stream_filename(fn) if use_filename then local fn_base = fn:gsub("%.[a-zA-Z0-9]+$", "") local fn_clean = fn_base:gsub("^%s*%b[]%s*", "") local leading_paren = fn_clean:match("^%s*%((.-)%)%s*[_%-]?%s*") if leading_paren and not leading_paren:match("^%d%d%d%d$") and not leading_paren:match("^%d+$") then fn_clean = fn_clean:gsub("^%s*%b()%s*[_%-]?%s*", "") end if not fn_clean:match("%-%d+$") and not fn_clean:match("%-%d+v%d+$") then fn_clean = fn_clean:gsub("%-[%w_%.]+$", "") end local parsed_title, parsed_season, parsed_episode, parsed_season_missing, parsed_year, parsed_twy = TitleParser.parse_season_episode(fn_clean) if parsed_title then info.is_tv = true info.season = parsed_season info.episode = parsed_episode info.season_missing = parsed_season_missing info.title = parsed_title info.title_with_year = parsed_twy if parsed_year then info.year = parsed_year end end local max_year = TitleParser.max_release_year() if info.is_tv and not info.year then local years_found = {} local seen_years = {} for y_str in fn_clean:gmatch("(%d%d%d%d)") do local y_num = tonumber(y_str) if y_num and y_num >= 1900 and y_num <= max_year then local is_resolution = fn_clean:find(y_str .. "[pPiI]") ~= nil if not is_resolution and not seen_years[y_num] then seen_years[y_num] = true table.insert(years_found, y_num) end end end if #years_found == 1 then info.year = years_found[1] elseif #years_found > 1 then info.year_ambiguous = true end end if not info.title then local parens_y_str = fn_clean:match("%((%d%d%d%d)%)") or fn_clean:match("%[(%d%d%d%d)%]") local parens_y = tonumber(parens_y_str) if parens_y and parens_y >= 1900 and parens_y <= max_year then local m_title = fn_clean:match("^(.-)[%._%-%s%(%[]+" .. parens_y_str) if m_title and m_title:match("%w") then info.year = parens_y info.title = TitleParser.clean_title(m_title) info.title_with_year = TitleParser.clean_title(fn_clean) end end if not info.title then local years_found = {} local seen_years = {} for y_str in fn_clean:gmatch("(%d%d%d%d)") do local y_num = tonumber(y_str) if y_num and y_num >= 1900 and y_num <= max_year then local is_resolution = fn_clean:find(y_str .. "[pPiI]") ~= nil if not is_resolution and not seen_years[y_num] then seen_years[y_num] = true table.insert(years_found, y_num) end end end if #years_found == 1 then local y = years_found[1] local m_title = fn_clean:match("^(.-)[%._%-%s%(]+" .. tostring(y) .. "[%._%-%s%)]*(.*)$") if not m_title or #m_title == 0 then m_title = fn_clean:match("^(.-)" .. tostring(y)) end if m_title and m_title:match("%w") then info.year = y info.title = TitleParser.clean_title(m_title) info.title_with_year = TitleParser.clean_title(fn_clean) else info.title = TitleParser.clean_title(fn_clean) info.year = nil end elseif #years_found > 1 then local y1, y2 = years_found[1], years_found[2] local adjacent = fn_clean:find(tostring(y1) .. "[%._%-%s]+" .. tostring(y2)) ~= nil if adjacent and math.abs(y1 - y2) <= 1 then info.year_ambiguous = true info.year = nil info.title = TitleParser.clean_title(fn_clean) else local last_y = years_found[#years_found] local m_title = fn_clean:match("^(.-)[%._%-%s%(]+" .. tostring(last_y) .. "[%._%-%s%)]*(.*)$") if m_title and m_title:match("%w") then info.year = last_y info.title = TitleParser.clean_title(m_title) info.title_with_year = TitleParser.clean_title(fn_clean) else info.title = TitleParser.clean_title(fn_clean) info.year = nil end end else info.title = TitleParser.clean_title(fn_clean) end end end end local fn_was_generic = fn and #fn > 0 and is_generic_stream_filename(fn) local parsed_title = nil local parsed_season = nil local parsed_episode = nil local parsed_season_missing = false local parsed_year = nil if fn_was_generic and raw_media_title and not raw_media_title:match("^av://") and not raw_media_title:match("^lavfi:") then local raw_clean = raw_media_title:gsub("%.[a-zA-Z0-9]+$", "") raw_clean = raw_clean:gsub("^%s*%b[]%s*", "") local leading_paren = raw_clean:match("^%s*%((.-)%)%s*[_%-]?%s*") if leading_paren and not leading_paren:match("^%d%d%d%d$") and not leading_paren:match("^%d+$") then raw_clean = raw_clean:gsub("^%s*%b()%s*[_%-]?%s*", "") end if not raw_clean:match("%-%d+$") and not raw_clean:match("%-%d+v%d+$") then raw_clean = raw_clean:gsub("%-[%w_%.]+$", "") end parsed_title, parsed_season, parsed_episode, parsed_season_missing, parsed_year = TitleParser.parse_season_episode(raw_clean) end if not info.title or #info.title == 0 or fn_was_generic then for key, val in pairs(metadata) do local k = string.lower(tostring(key)) if (k == "show" or k == "series") and not info.title then info.title = TitleParser.clean_title(tostring(val)) end end if parsed_title then info.title = parsed_title info.is_tv = true info.season = parsed_season info.episode = parsed_episode info.season_missing = parsed_season_missing if parsed_year then info.year = parsed_year end elseif not info.title and raw_media_title and not raw_media_title:match("^av://") and not raw_media_title:match("^lavfi:") then info.title = TitleParser.clean_title(raw_media_title) end if info.title and is_generic_stream_filename(info.title) then info.title = nil end end return info end local function clean_title(raw) return TitleParser.clean_title(raw) end _G.TitleParser = TitleParser local function format_hms(sec) if type(sec) ~= "number" or sec ~= sec or sec == math.huge or sec < 0 then sec = 0 end local total = math.floor(sec) local hours = math.floor(total / 3600) local minutes = math.floor((total % 3600) / 60) local seconds = total % 60 return string.format("%d:%02d:%02d", hours, minutes, seconds) end local function make_segment(seg_type, label, start_sec, end_sec, provider) return { type = seg_type, label = label, start_sec = math.max(0, start_sec + user_opts.start_offset), end_sec = end_sec + user_opts.end_offset, provider = provider, skipped = false, } end local function parse_keywords(keyword_str) local keywords = {} if not keyword_str or #keyword_str == 0 then return keywords end for kw in string.gmatch(keyword_str, "([^,]+)") do local trimmed = kw:match("^%s*(.-)%s*$") if trimmed and #trimmed > 0 then table.insert(keywords, trimmed:lower()) end end return keywords end local function is_whole_word_match(text, keyword) if #keyword <= 3 then local pattern = "%f[%w]" .. keyword:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "%f[%W]" return text:lower():match(pattern) ~= nil else return text:lower():find(keyword:lower(), 1, true) ~= nil end end local function chapter_matches_keywords(chapter_title, keywords) if not chapter_title or #chapter_title == 0 then return false end local normalized = chapter_title:lower() normalized = normalized:gsub("[%p]", " ") normalized = normalized:gsub("%s+", " ") normalized = normalized:match("^%s*(.-)%s*$") or "" for _, kw in ipairs(keywords) do if is_whole_word_match(normalized, kw) then return true end end return false end local function check_overlap(chapter_start, chapter_end, existing_segments, threshold) threshold = threshold or user_opts.chapter_skip_overlap_threshold local chapter_duration = chapter_end - chapter_start if chapter_duration <= 0 then return true end for _, seg in ipairs(existing_segments) do local overlap_start = math.max(chapter_start, seg.start_sec) local overlap_end = math.min(chapter_end, seg.end_sec) local overlap_duration = math.max(0, overlap_end - overlap_start) local overlap_ratio = overlap_duration / chapter_duration if overlap_ratio >= threshold then return true, overlap_ratio end end return false, 0 end local function detect_chapter_segments(chapters, duration, existing_segments, media_info) local detected = {} if not chapters or type(chapters) ~= "table" or #chapters == 0 then log_debug("chapter-skip: no chapters") return detected end if not duration or duration <= 0 then log_debug("chapter-skip: invalid duration") return detected end local keyword_lists = { intro = parse_keywords(user_opts.chapter_skip_keywords_intro), outro = parse_keywords(user_opts.chapter_skip_keywords_outro), recap = parse_keywords(user_opts.chapter_skip_keywords_recap), preview = parse_keywords(user_opts.chapter_skip_keywords_preview), } local category_enabled = { intro = user_opts.chapter_skip_intro, outro = user_opts.chapter_skip_outro, recap = user_opts.chapter_skip_recap, preview = user_opts.chapter_skip_preview, } local sorted_chapters = {} for _, ch in ipairs(chapters) do if ch and ch.time then table.insert(sorted_chapters, { time = tonumber(ch.time) or 0, title = ch.title or "", }) end end table.sort(sorted_chapters, function(a, b) return a.time < b.time end) if #sorted_chapters == 0 then return detected end local min_dur = user_opts.chapter_skip_min_duration local max_dur = user_opts.chapter_skip_max_duration for i, ch in ipairs(sorted_chapters) do local start_time = ch.time local end_time = (i < #sorted_chapters) and sorted_chapters[i + 1].time or duration local chapter_duration = end_time - start_time if chapter_duration < min_dur or chapter_duration > max_dur then log_debug("chapter-skip: '%s' @%.2fs skipped (dur %.1fs out of bounds)", ch.title, start_time, chapter_duration) goto continue end local matched = false for cat, keywords in pairs(keyword_lists) do if category_enabled[cat] and #keywords > 0 then local normalized = ch.title:lower() normalized = normalized:gsub("[%p]", " ") normalized = normalized:gsub("%s+", " ") normalized = normalized:match("^%s*(.-)%s*$") or "" for _, kw in ipairs(keywords) do if is_whole_word_match(normalized, kw) then local overlaps, ratio = check_overlap(start_time, end_time, existing_segments) if overlaps then log_debug("chapter-skip: '%s' matches %s but overlaps provider (%.0f%%)", ch.title, cat, ratio * 100) else local label = cat:gsub("^%l", string.upper) if cat == "outro" and media_info and not media_info.is_tv then label = "Credits" end table.insert(detected, make_segment(cat, label, start_time, end_time, "Chapters")) log_info("Detected %s segment from chapter: '%s' at %.2fs-%.2fs", cat, ch.title, start_time, end_time) end matched = true break end end if matched then break end end end if not matched then log_debug("chapter-skip: no match for '%s'", ch.title) end ::continue:: end return detected end local function build_auth_header(key, header_prefix) if key and #key > 0 then return { header_prefix .. key } end return {} end local state = { file_id = 0, path = nil, filename = nil, duration = 0, media_info = nil, segments = {}, segments_loaded = false, query_in_progress = false, active_segment = nil, button_visible = false, mouse_hover = false, mbtn_bound = false, auto_skip_timer = nil, auto_skip_target_time = nil, button_timeout_timer = nil, last_time = nil, display_w = 1920, display_h = 1080, box = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 }, overlay = nil, submission_overlay = nil, submission_visible = false, submission_data = { segment_type = "intro", start_sec = nil, end_sec = nil, status = "", status_color = "FFFFFF", submitting = false, }, submission_selected_field = "segment_type", submission_keybinds_active = false, submission_status_timer = nil, submission_buttons = {}, submission_hover = nil, submission_mbtn_bound = false, } local function extract_media_metadata() local path = mp.get_property("path") or "" local filename = mp.get_property("filename") or "" local raw_media_title = mp.get_property("media-title") or "" local metadata = mp.get_property_native("metadata") or {} local info = TitleParser.extract_media_metadata(path, filename, raw_media_title, metadata) log_debug("Media parsed: title='%s', is_tv=%s, S=%s, E=%s, Year=%s, imdb=%s, tmdb=%s, tvdb=%s", tostring(info.title), tostring(info.is_tv), tostring(info.season), tostring(info.episode), tostring(info.year), tostring(info.imdb_id), tostring(info.tmdb_id), tostring(info.tvdb_id)) return info end local function async_http_get(url, headers, timeout_sec, callback) local body_file = os.tmpname() local args = { "curl", "-s", "-S", "--max-time", tostring(timeout_sec or user_opts.request_timeout), "--connect-timeout", "4", "-H", "User-Agent: mpv-skip-segment/1.0", "-H", "Accept: application/json", "-w", "%{http_code}", "-o", body_file, } if headers then for _, h in ipairs(headers) do table.insert(args, "-H") table.insert(args, h) end end table.insert(args, url) log_debug("HTTP GET: %s", url) mp.command_native_async({ name = "subprocess", args = args, capture_stdout = true, capture_stderr = true, playback_only = false, }, function(success, res, err) local http_code = 0 if res and res.stdout then http_code = tonumber(res.stdout:match("%d+")) or 0 end local body = "" local f = io.open(body_file, "r") if f then body = f:read("*a") or "" f:close() os.remove(body_file) end if not success or not res then log_debug("curl execution error: %s", tostring(err)) callback(false, nil, err or "curl execution failed", http_code) return end if res.status ~= 0 then local err_msg = string.format("curl error (exit code %d): %s", res.status, res.stderr or "") log_debug("%s", err_msg) callback(false, nil, err_msg, http_code) return end local parsed, parse_err = utils.parse_json(body) if parse_err or not parsed then log_debug("JSON parse error from %s: %s", url, tostring(parse_err)) callback(false, body, "invalid JSON: " .. tostring(parse_err), http_code) return end callback(true, parsed, nil, http_code) end) end local function async_http_post(url, headers, data, timeout_sec, callback) local json_data = utils.format_json(data) local status_file = os.tmpname() local args = { "curl", "-s", "-S", "--max-time", tostring(timeout_sec or user_opts.request_timeout), "--connect-timeout", "4", "-H", "User-Agent: mpv-skip-segment/1.0", "-H", "Accept: application/json", "-H", "Content-Type: application/json", "-X", "POST", "-d", json_data, "-w", "%{http_code}", "-o", status_file, } if headers then for _, h in ipairs(headers) do table.insert(args, "-H") table.insert(args, h) end end table.insert(args, url) log_debug("HTTP POST: %s", url) mp.command_native_async({ name = "subprocess", args = args, capture_stdout = true, capture_stderr = true, playback_only = false, }, function(success, res, err) local http_code = 0 if res and res.stdout then http_code = tonumber(res.stdout:match("%d+")) or 0 end local body = "" local f = io.open(status_file, "r") if f then body = f:read("*a") or "" f:close() os.remove(status_file) end if not success or not res then log_debug("curl execution error: %s", tostring(err)) callback(false, nil, err or "curl execution failed", http_code) return end if res.status ~= 0 then local err_msg = string.format("curl error (exit code %d): %s", res.status, res.stderr or "") log_debug("%s", err_msg) callback(false, nil, err_msg, http_code) return end local parsed, parse_err = utils.parse_json(body) if parse_err or not parsed then log_debug("JSON parse error from %s: %s", url, tostring(parse_err)) callback(false, body, "invalid JSON: " .. tostring(parse_err), http_code) return end callback(true, parsed, nil, http_code) end) end local function resolve_title_to_imdb(media_info, current_file_id, callback) if media_info.imdb_id then callback(true) return end if media_info.year_ambiguous then log_info("Filename contains multiple conflicting years; rejecting title resolution to avoid false matches") callback(false) return end if not media_info.title or #media_info.title == 0 then callback(media_info.tmdb_id ~= nil or media_info.tvdb_id ~= nil) return end if user_opts.tmdb_api_key and #user_opts.tmdb_api_key > 0 then local function tmdb_to_candidate(r) local cand_name = r.title or r.name or "" local date_str = r.release_date or r.first_air_date or "" local cand_year = tonumber(date_str:match("^(%d%d%d%d)")) return { id = r.id, name = cand_name, year = cand_year and tostring(cand_year) or nil } end local function pick_best_tmdb(results, query_title, target_year) local candidates = {} for _, r in ipairs(results) do table.insert(candidates, tmdb_to_candidate(r)) end local best_c, best_s = nil, 0 for rank, c in ipairs(candidates) do local s if media_info.is_tv then s = TitleParser.score_series_candidate(c, query_title, target_year, candidates) else s = TitleParser.score_movie_candidate(c, query_title, target_year, candidates) end s = s - (rank - 1) * 0.5 log_debug("TMDb candidate #%d '%s' (year=%s) score=%.1f", rank, c.name, tostring(c.year), s) if s > best_s then best_s = s best_c = c end end local min_score = (#candidates == 1) and 10 or 300 if best_c and best_s >= min_score then return best_c, best_s end return nil, best_s end local endpoint = media_info.is_tv and "search/tv" or "search/movie" local function build_tmdb_url(query, year) local u = string.format("https://api.themoviedb.org/3/%s?api_key=%s&query=%s", endpoint, user_opts.tmdb_api_key, url_encode(query)) if year then if media_info.is_tv then u = u .. "&first_air_date_year=" .. tostring(year) else u = u .. "&primary_release_year=" .. tostring(year) end end return u end local function fallback_to_cinemeta() log_debug("TMDb found no suitable match for '%s'; falling back to Cinemeta", media_info.title) local catalog_type = media_info.is_tv and "series" or "movie" local cine_url = string.format("https://v3-cinemeta.strem.io/catalog/%s/top/search=%s.json", catalog_type, url_encode(media_info.title)) async_http_get(cine_url, nil, 6, function(success2, data2, err2) if state.file_id ~= current_file_id then return end if success2 and data2 and data2.metas and #data2.metas > 0 then local best_meta, best_score = nil, 0 for rank, m in ipairs(data2.metas) do local score = 0 if media_info.is_tv then score = TitleParser.score_series_candidate(m, media_info.title, media_info.year, data2.metas) else score = TitleParser.score_movie_candidate(m, media_info.title, media_info.year, data2.metas) end score = score - (rank - 1) * 0.5 local cand_year = tonumber(m.year) or tonumber(m.releaseInfo) log_debug("Cinemeta fallback candidate #%d '%s' (year=%s) score=%.1f", rank, m.name or "", tostring(cand_year), score) if score > best_score then best_score = score best_meta = m end end local id = best_meta and (best_meta.imdb_id or best_meta.id) local min_score = (#data2.metas == 1) and 10 or 300 if id and id:match("^tt%d+") and best_score >= min_score then if media_info.is_tv and media_info.season_missing then local meta_url = string.format("https://v3-cinemeta.strem.io/meta/series/%s.json", id) async_http_get(meta_url, nil, 6, function(meta_ok, meta_data, meta_err) if state.file_id ~= current_file_id then return end if meta_ok and meta_data and meta_data.meta then local ok, cnt = TitleParser.validate_series_seasons(meta_data.meta.videos, media_info) if not ok then log_warn("Series '%s' has %d seasons but season omitted; rejecting", best_meta.name or "?", cnt) callback(false) return end end media_info.imdb_id = id log_info("Resolved '%s' to IMDb ID %s via Cinemeta fallback (score=%.1f)", media_info.title, id, best_score) callback(true) end) return else media_info.imdb_id = id log_info("Resolved '%s' to IMDb ID %s via Cinemeta fallback (score=%.1f)", media_info.title, id, best_score) callback(true) return end end end if media_info.title_with_year and media_info.title_with_year ~= media_info.title then local retry_url = string.format("https://v3-cinemeta.strem.io/catalog/%s/top/search=%s.json", catalog_type, url_encode(media_info.title_with_year)) async_http_get(retry_url, nil, 6, function(retry_ok, retry_data, retry_err) if state.file_id ~= current_file_id then return end if retry_ok and retry_data and retry_data.metas and #retry_data.metas > 0 then local r_best_meta, r_best_score = nil, 0 for rank, m in ipairs(retry_data.metas) do local score = 0 if media_info.is_tv then score = TitleParser.score_series_candidate(m, media_info.title_with_year, nil, retry_data.metas) else score = TitleParser.score_movie_candidate(m, media_info.title_with_year, nil, retry_data.metas) end score = score - (rank - 1) * 0.5 if score > r_best_score then r_best_score = score r_best_meta = m end end local r_id = r_best_meta and (r_best_meta.imdb_id or r_best_meta.id) local r_min = (#retry_data.metas == 1) and 10 or 300 if r_id and r_id:match("^tt%d+") and r_best_score >= r_min then media_info.imdb_id = r_id media_info.title = media_info.title_with_year log_info("Resolved '%s' (year-in-title fallback) to IMDb ID %s via Cinemeta (score=%.1f)", media_info.title, r_id, r_best_score) callback(true) return end end log_debug("Could not resolve '%s' via Cinemeta", media_info.title) callback(media_info.tmdb_id ~= nil or media_info.tvdb_id ~= nil) end) return end log_debug("Could not resolve '%s' via Cinemeta", media_info.title) callback(media_info.tmdb_id ~= nil or media_info.tvdb_id ~= nil) end) end local function on_tmdb_match(best_c, log_note) media_info.tmdb_id = best_c.id log_info("Resolved title '%s' to TMDb ID %d via TMDb API%s", media_info.title, media_info.tmdb_id, log_note or "") local function fetch_external_ids() local ext_type = media_info.is_tv and "tv" or "movie" local ext_url = string.format("https://api.themoviedb.org/3/%s/%d/external_ids?api_key=%s", ext_type, media_info.tmdb_id, user_opts.tmdb_api_key) async_http_get(ext_url, nil, 4, function(ext_ok, ext_data, ext_err) if state.file_id ~= current_file_id then return end if ext_ok and ext_data then if ext_data.imdb_id then media_info.imdb_id = ext_data.imdb_id log_info("Resolved TMDb ID %d to IMDb ID %s", media_info.tmdb_id, media_info.imdb_id) end if ext_data.tvdb_id and not media_info.tvdb_id then media_info.tvdb_id = ext_data.tvdb_id log_info("Resolved TMDb ID %d to TVDB ID %d", media_info.tmdb_id, media_info.tvdb_id) end end callback(true) end) end if media_info.is_tv and media_info.season_missing then local tv_url = string.format("https://api.themoviedb.org/3/tv/%d?api_key=%s", media_info.tmdb_id, user_opts.tmdb_api_key) async_http_get(tv_url, nil, 4, function(tv_ok, tv_data) if state.file_id ~= current_file_id then return end if tv_ok and tv_data and tv_data.number_of_seasons and tv_data.number_of_seasons > 1 then log_warn("Show '%s' has %d seasons, but season was omitted in filename; rejecting ambiguous episode", media_info.title, tv_data.number_of_seasons) callback(false) return else media_info.season = 1 media_info.season_missing = false fetch_external_ids() end end) else fetch_external_ids() end end local url1 = build_tmdb_url(media_info.title, media_info.year) async_http_get(url1, nil, 6, function(success, data, err) if state.file_id ~= current_file_id then return end if success and data and data.results and #data.results > 0 then local best_c, best_s = pick_best_tmdb(data.results, media_info.title, media_info.year) if best_c then on_tmdb_match(best_c) return end end local function try_no_year() if not media_info.year then try_title_with_year() return end local url2 = build_tmdb_url(media_info.title, nil) async_http_get(url2, nil, 6, function(ok2, data2, err2) if state.file_id ~= current_file_id then return end if ok2 and data2 and data2.results and #data2.results > 0 then local best_c2, best_s2 = pick_best_tmdb(data2.results, media_info.title, media_info.year) if best_c2 then on_tmdb_match(best_c2, " (no-year fallback)") return end end try_title_with_year() end) end function try_title_with_year() if not media_info.title_with_year or media_info.title_with_year == media_info.title then fallback_to_cinemeta() return end local url3 = build_tmdb_url(media_info.title_with_year, nil) async_http_get(url3, nil, 6, function(ok3, data3, err3) if state.file_id ~= current_file_id then return end if ok3 and data3 and data3.results and #data3.results > 0 then local best_c3, best_s3 = pick_best_tmdb(data3.results, media_info.title_with_year, nil) if best_c3 then media_info.title = media_info.title_with_year on_tmdb_match(best_c3, " (title-with-year fallback)") return end end fallback_to_cinemeta() end) end try_no_year() end) return end local catalog_type = media_info.is_tv and "series" or "movie" local url = string.format("https://v3-cinemeta.strem.io/catalog/%s/top/search=%s.json", catalog_type, url_encode(media_info.title)) async_http_get(url, nil, 6, function(success, data, err) if state.file_id ~= current_file_id then return end if success and data and data.metas and #data.metas > 0 then local best_meta, best_score = nil, 0 for rank, m in ipairs(data.metas) do local score = 0 if media_info.is_tv then score = TitleParser.score_series_candidate(m, media_info.title, media_info.year, data.metas) else score = TitleParser.score_movie_candidate(m, media_info.title, media_info.year, data.metas) end score = score - (rank - 1) * 0.5 local cand_year = tonumber(m.year) or tonumber(m.releaseInfo) log_debug("Cinemeta candidate #%d '%s' (year=%s) score=%.1f", rank, m.name or "", tostring(cand_year), score) if score > best_score then best_score = score best_meta = m end end local id = best_meta and (best_meta.imdb_id or best_meta.id) local min_score = 300 if #data.metas == 1 then min_score = 10 end if id and id:match("^tt%d+") and best_score >= min_score then if media_info.is_tv and media_info.season_missing then local meta_url = string.format("https://v3-cinemeta.strem.io/meta/series/%s.json", id) async_http_get(meta_url, nil, 6, function(meta_ok, meta_data, meta_err) if state.file_id ~= current_file_id then return end if meta_ok and meta_data and meta_data.meta then local ok, cnt = TitleParser.validate_series_seasons(meta_data.meta.videos, media_info) if not ok then log_warn("Series '%s' has %d seasons, but season was omitted in filename; rejecting match to prevent wrong skip segments", best_meta.name or "?", cnt) callback(false) return end end media_info.imdb_id = id log_info("Resolved series '%s' (S%dE%d) to IMDb ID %s via Cinemeta (score=%.1f, name='%s')", media_info.title, media_info.season or 1, media_info.episode or 1, id, best_score, best_meta.name or "?") callback(true) end) return else media_info.imdb_id = id log_info("Resolved title '%s'%s to IMDb ID %s via Cinemeta (score=%.1f, name='%s')", media_info.title, media_info.year and (" (" .. tostring(media_info.year) .. ")") or "", id, best_score, best_meta.name or "?") callback(true) return end end end if media_info.title_with_year and media_info.title_with_year ~= media_info.title then local retry_url = string.format("https://v3-cinemeta.strem.io/catalog/%s/top/search=%s.json", catalog_type, url_encode(media_info.title_with_year)) async_http_get(retry_url, nil, 6, function(retry_ok, retry_data, retry_err) if state.file_id ~= current_file_id then return end if retry_ok and retry_data and retry_data.metas and #retry_data.metas > 0 then local r_best_meta, r_best_score = nil, 0 for rank, m in ipairs(retry_data.metas) do local score = 0 if media_info.is_tv then score = TitleParser.score_series_candidate(m, media_info.title_with_year, nil, retry_data.metas) else score = TitleParser.score_movie_candidate(m, media_info.title_with_year, nil, retry_data.metas) end score = score - (rank - 1) * 0.5 if score > r_best_score then r_best_score = score r_best_meta = m end end local r_id = r_best_meta and (r_best_meta.imdb_id or r_best_meta.id) local r_min_score = (#retry_data.metas == 1) and 10 or 300 if r_id and r_id:match("^tt%d+") and r_best_score >= r_min_score then media_info.imdb_id = r_id media_info.title = media_info.title_with_year log_info("Resolved title '%s' (fallback with year in title) to IMDb ID %s via Cinemeta (score=%.1f, name='%s')", media_info.title, r_id, r_best_score, r_best_meta.name or "?") callback(true) return end end log_debug("Could not resolve title '%s' to an IMDb ID via Cinemeta", media_info.title) callback(media_info.tmdb_id ~= nil or media_info.tvdb_id ~= nil) end) return end log_debug("Could not resolve title '%s' to an IMDb ID via Cinemeta", media_info.title) callback(media_info.tmdb_id ~= nil or media_info.tvdb_id ~= nil) end) end local function query_theintrodb(media_info, current_file_id, callback) if not media_info.tvdb_id and not media_info.tmdb_id and not media_info.imdb_id then callback(false, nil, "Missing ID (tvdb_id, tmdb_id, or imdb_id)") return end if media_info.is_tv and (not media_info.season or not media_info.episode) then callback(false, nil, "TheIntroDB requires season and episode for TV shows") return end local params = {} if media_info.tvdb_id then table.insert(params, "tvdb_id=" .. tostring(media_info.tvdb_id)) elseif media_info.tmdb_id then table.insert(params, "tmdb_id=" .. tostring(media_info.tmdb_id)) elseif media_info.imdb_id then table.insert(params, "imdb_id=" .. tostring(media_info.imdb_id)) end if media_info.is_tv then table.insert(params, "season=" .. tostring(media_info.season)) table.insert(params, "episode=" .. tostring(media_info.episode)) end if state.duration and state.duration > 0 then table.insert(params, "duration_ms=" .. tostring(round(state.duration * 1000))) end local url = "https://api.theintrodb.org/v4/media?" .. table.concat(params, "&") local headers = build_auth_header(user_opts.theintrodb_api_key, "Authorization: Bearer ") async_http_get(url, headers, user_opts.request_timeout, function(success, data, err, http_code) if state.file_id ~= current_file_id then return end if not success or not data then callback(false, nil, err or "TheIntroDB request failed") return end if http_code == 404 then log_info("TheIntroDB returned 0 segment(s)") callback(true, {}, nil) return end if data.error then callback(false, nil, "TheIntroDB API error: " .. tostring(data.error)) return end local normalized = {} local function add_ranges(list, seg_type, label) if not list or type(list) ~= "table" then return end for _, item in ipairs(list) do local s_ms = item.start_ms local e_ms = item.end_ms local is_no_segment = false if (seg_type == "intro" or seg_type == "recap") and e_ms == 0 then is_no_segment = true elseif (seg_type == "outro" or seg_type == "preview") and (s_ms == 0 or e_ms == 0) then is_no_segment = true end if not is_no_segment then local s_sec = s_ms and (s_ms / 1000.0) or 0.0 local e_sec = e_ms and (e_ms / 1000.0) or (state.duration > 0 and state.duration or nil) if e_sec and (e_sec - s_sec) >= user_opts.min_segment_duration then table.insert(normalized, make_segment(seg_type, label, s_sec, e_sec, "TheIntroDB")) end end end end local outro_label = media_info.is_tv and "Outro" or "Credits" if user_opts.skip_intro and user_opts.theintrodb_intro_segment then add_ranges(data.intro, "intro", "Intro") end if user_opts.skip_recap and user_opts.theintrodb_recap_segment then add_ranges(data.recap, "recap", "Recap") end if user_opts.skip_outro and user_opts.theintrodb_outro_segment then add_ranges(data.credits, "outro", outro_label) end if user_opts.skip_preview and user_opts.theintrodb_preview_segment then add_ranges(data.preview, "preview", "Preview") end log_info("TheIntroDB returned %d segment(s)", #normalized) callback(true, normalized, nil) end) end local function query_introdb(media_info, current_file_id, callback) if not media_info.imdb_id then callback(false, nil, "IntroDB requires imdb_id") return end local is_movie = not media_info.is_tv if not is_movie and (not media_info.season or not media_info.episode) then callback(false, nil, "IntroDB requires season and episode for TV shows") return end local url if is_movie then url = string.format("https://api.introdb.app/segments?imdb_id=%s&is_movie=true", media_info.imdb_id) else url = string.format("https://api.introdb.app/segments?imdb_id=%s&season=%d&episode=%d", media_info.imdb_id, media_info.season, media_info.episode) end local headers = build_auth_header(user_opts.introdb_api_key, "X-API-Key: ") async_http_get(url, headers, user_opts.request_timeout, function(success, data, err) if state.file_id ~= current_file_id then return end if not success or not data then callback(false, nil, err or "IntroDB request failed") return end if data.error then callback(false, nil, "IntroDB API error: " .. tostring(data.error)) return end local normalized = {} local function add_segment(item, seg_type, label) if not item or type(item) ~= "table" then return end local s_sec = item.start_sec or (item.start_ms and (item.start_ms / 1000.0)) or 0.0 local e_sec = item.end_sec or (item.end_ms and (item.end_ms / 1000.0)) or nil if e_sec and (e_sec - s_sec) >= user_opts.min_segment_duration then table.insert(normalized, make_segment(seg_type, label, s_sec, e_sec, "IntroDB")) end end if user_opts.skip_intro then add_segment(data.intro, "intro", "Intro") end if user_opts.skip_recap then add_segment(data.recap, "recap", "Recap") end if user_opts.skip_outro then add_segment(data.outro, "outro", is_movie and "Credits" or "Outro") end if user_opts.skip_post_credits then add_segment(data.post_credits, "post_credits", "Post-Credits") end log_info("IntroDB returned %d segment(s)", #normalized) callback(true, normalized, nil) end) end local function query_skipdb(media_info, current_file_id, callback) if not media_info.imdb_id then callback(false, nil, "SkipDB requires imdb_id") return end local url = "https://api.skipdb.tv/api/segments?imdb_id=" .. media_info.imdb_id if media_info.is_tv and media_info.season and media_info.episode then url = url .. "&season=" .. tostring(media_info.season) .. "&episode=" .. tostring(media_info.episode) end if state.duration and state.duration > 0 then url = url .. "&duration=" .. tostring(math.floor(state.duration)) end local headers = build_auth_header(user_opts.skipdb_api_key, "Authorization: Bearer ") async_http_get(url, headers, user_opts.request_timeout, function(success, data, err) if state.file_id ~= current_file_id then return end if not success or not data then callback(false, nil, err or "SkipDB request failed") return end if data.error then callback(false, nil, "SkipDB API error: " .. tostring(data.error)) return end local segs = data.segments if type(segs) ~= "table" then callback(false, nil, "SkipDB: unexpected response format") return end local normalized = {} local function add_segment(item, seg_type, label) if not item or type(item) ~= "table" then return end if item.start_ms == 0 and item.end_ms == 0 then return end local s_sec = (item.start_ms or 0) / 1000.0 local e_sec = item.end_ms and (item.end_ms / 1000.0) or nil if e_sec and (e_sec - s_sec) >= user_opts.min_segment_duration then table.insert(normalized, make_segment(seg_type, label, s_sec, e_sec, "SkipDB")) end end if user_opts.skip_intro then add_segment(segs.intro, "intro", "Intro") end if user_opts.skip_recap then add_segment(segs.recap, "recap", "Recap") end if user_opts.skip_outro then add_segment(segs.outro, "outro", "Outro") end if user_opts.skip_preview then add_segment(segs.preview, "preview", "Preview") end log_info("SkipDB returned %d segment(s)", #normalized) callback(true, normalized, nil) end) end local provider_fns = { theintrodb = query_theintrodb, introdb = query_introdb, skipdb = query_skipdb, } local SUBMISSION_SEGMENT_TYPES = { intro = { theintrodb = "intro", introdb = "intro", skipdb = "intro" }, recap = { theintrodb = "recap", introdb = "recap", skipdb = "recap" }, outro = { theintrodb = "credits", introdb = "outro", skipdb = "outro" }, preview = { theintrodb = "preview", introdb = nil, skipdb = "preview" }, post_credits = { theintrodb = nil, introdb = "post-credits", skipdb = nil }, } local SEGMENT_DURATION_LIMITS = { intro = { min = 5, max = 200 }, recap = { min = 5, max = 1200 }, outro = { min = 5, max = 1800 }, preview = { min = 5, max = 1800 }, post_credits = { min = 5, max = 1800 }, } local function format_media_title(media_info) local title = media_info.title or "Unknown" if media_info.is_tv and media_info.season and media_info.episode then title = string.format("%s Season %d Episode %d", title, media_info.season, media_info.episode) end return title end local function validate_submission_data(data) local seg_type = data.segment_type local start_sec = data.start_sec local end_sec = data.end_sec if not seg_type or not VALID_SEGMENT_TYPES[seg_type] then return false, "Invalid segment type" end if start_sec == nil or end_sec == nil then return false, "Start and end times are required" end if start_sec < 0 or end_sec < 0 then return false, "Times cannot be negative" end if start_sec >= end_sec then return false, "Start time must be before end time" end local duration = end_sec - start_sec local limits = SEGMENT_DURATION_LIMITS[seg_type] if limits then if duration < limits.min then return false, string.format("Segment too short (minimum %ds for %s)", limits.min, seg_type) end if duration > limits.max then return false, string.format("Segment too long (maximum %ds for %s)", limits.max, seg_type) end end if end_sec > 21600 then return false, "Times cannot exceed 6 hours" end return true, nil end local function interpret_submission_error(http_code, err, provider) if http_code and http_code > 0 then if http_code == 401 or http_code == 403 then return string.format("%s: Invalid API key (HTTP %d)", provider, http_code) elseif http_code == 400 or http_code == 422 then return string.format("%s: Validation error (HTTP %d): %s", provider, http_code, tostring(err or "")) elseif http_code == 409 then return string.format("%s: Conflict (HTTP %d): %s", provider, http_code, tostring(err or "already submitted")) elseif http_code == 429 then return string.format("%s: Rate limited (HTTP 429), retry later", provider) elseif http_code >= 500 then return string.format("%s: Server error (HTTP %d)", provider, http_code) else return string.format("%s: Submission failed (HTTP %d): %s", provider, http_code, tostring(err or "")) end end return string.format("%s: %s", provider, tostring(err or "submission failed")) end local function submit_to_theintrodb(media_info, submission_data, callback) if not user_opts.theintrodb_submit_enabled or not user_opts.theintrodb_api_key or #user_opts.theintrodb_api_key == 0 then callback(false, "TheIntroDB submission disabled or no API key") return end local seg_type = SUBMISSION_SEGMENT_TYPES[submission_data.segment_type].theintrodb if not seg_type then callback(false, "Segment type not supported by TheIntroDB") return end if not media_info.tvdb_id and not media_info.tmdb_id and not media_info.imdb_id then callback(false, "TheIntroDB requires tvdb_id, tmdb_id, or imdb_id") return end if media_info.is_tv and (not media_info.season or not media_info.episode) then callback(false, "TheIntroDB requires season and episode for TV shows") return end local body = { type = media_info.is_tv and "tv" or "movie", segment = seg_type, start_sec = submission_data.start_sec, end_sec = submission_data.end_sec, } if media_info.tvdb_id then body.tvdb_id = media_info.tvdb_id elseif media_info.tmdb_id then body.tmdb_id = media_info.tmdb_id elseif media_info.imdb_id then body.imdb_id = media_info.imdb_id end if media_info.imdb_id and not body.imdb_id then body.imdb_id = media_info.imdb_id end if media_info.is_tv then body.season = media_info.season body.episode = media_info.episode end if state.duration and state.duration > 0 then local dur_ms = round(state.duration * 1000) if dur_ms >= 300000 and dur_ms <= 21600000 then body.video_duration_ms = dur_ms end end local headers = build_auth_header(user_opts.theintrodb_api_key, "Authorization: Bearer ") local url = "https://api.theintrodb.org/v4/submit" async_http_post(url, headers, body, user_opts.request_timeout, function(success, data, err, http_code) if success and http_code >= 200 and http_code < 300 and data and data.ok ~= false and not data.error then callback(true, data) else local err_msg = (data and type(data) == "table" and data.error) or err callback(false, interpret_submission_error(http_code, err_msg, "TheIntroDB")) end end) end local function submit_to_introdb(media_info, submission_data, callback) if not user_opts.introdb_submit_enabled or not user_opts.introdb_api_key or #user_opts.introdb_api_key == 0 then callback(false, "IntroDB submission disabled or no API key") return end local seg_type = SUBMISSION_SEGMENT_TYPES[submission_data.segment_type].introdb if not seg_type then callback(false, "Segment type not supported by IntroDB") return end if seg_type == "post-credits" and media_info.is_tv then callback(false, "Post-credits only supported for movies on IntroDB") return end if not media_info.imdb_id then callback(false, "IntroDB requires IMDb ID") return end local body = { segment_type = seg_type, imdb_id = media_info.imdb_id, start_sec = submission_data.start_sec, end_sec = submission_data.end_sec, } if media_info.is_tv then body.is_movie = false body.season = media_info.season body.episode = media_info.episode else body.is_movie = true end if media_info.tvdb_id then body.tvdb_id = media_info.tvdb_id end if media_info.tmdb_id then body.tmdb_id = media_info.tmdb_id end local headers = build_auth_header(user_opts.introdb_api_key, "X-API-Key: ") local url = "https://api.introdb.app/submit" async_http_post(url, headers, body, user_opts.request_timeout, function(success, data, err, http_code) if success then callback(true, data) else callback(false, interpret_submission_error(http_code, err, "IntroDB")) end end) end local function submit_to_skipdb(media_info, submission_data, callback) if not user_opts.skipdb_submit_enabled or not user_opts.skipdb_api_key or #user_opts.skipdb_api_key == 0 then callback(false, "SkipDB submission disabled or no API key") return end local seg_type = SUBMISSION_SEGMENT_TYPES[submission_data.segment_type].skipdb if not seg_type then callback(false, "Segment type not supported by SkipDB") return end if not media_info.imdb_id then callback(false, "SkipDB requires IMDb ID") return end local body = { imdb_id = media_info.imdb_id, segment_type = seg_type, start_ms = round(submission_data.start_sec * 1000), end_ms = round(submission_data.end_sec * 1000), duration_ms = state.duration > 0 and round(state.duration * 1000) or nil, } if media_info.is_tv then body.season = media_info.season body.episode = media_info.episode end local headers = build_auth_header(user_opts.skipdb_api_key, "Authorization: Bearer ") local url = "https://api.skipdb.tv/api/segments" async_http_post(url, headers, body, user_opts.request_timeout, function(success, data, err, http_code) if success then callback(true, data) else callback(false, interpret_submission_error(http_code, err, "SkipDB")) end end) end local function submit_to_all_providers(media_info, submission_data, callback) local results = {} local pending = 0 local function check_done() pending = pending - 1 if pending == 0 then callback(results) end end if user_opts.theintrodb_submit_enabled and user_opts.theintrodb_api_key and #user_opts.theintrodb_api_key > 0 then pending = pending + 1 submit_to_theintrodb(media_info, submission_data, function(success, data) results.theintrodb = { success = success, data = data } check_done() end) end if user_opts.introdb_submit_enabled and user_opts.introdb_api_key and #user_opts.introdb_api_key > 0 then pending = pending + 1 submit_to_introdb(media_info, submission_data, function(success, data) results.introdb = { success = success, data = data } check_done() end) end if user_opts.skipdb_submit_enabled and user_opts.skipdb_api_key and #user_opts.skipdb_api_key > 0 then pending = pending + 1 submit_to_skipdb(media_info, submission_data, function(success, data) results.skipdb = { success = success, data = data } check_done() end) end if pending == 0 then callback({}) end end local SUBMISSION_TYPE_ORDER = { "intro", "recap", "outro", "preview", "post_credits" } local SUBMISSION_TYPE_LABELS = { intro = "Intro", recap = "Recap", outro = "Outro", preview = "Preview", post_credits = "Post-Credits", } local function format_submission_time(sec) if type(sec) ~= "number" or sec ~= sec or sec < 0 then return "--:--:--" end return format_hms(sec) end local function cycle_submission_segment_type() local order = SUBMISSION_TYPE_ORDER local current = state.submission_data.segment_type local idx = 1 for i, t in ipairs(order) do if t == current then idx = i break end end local next_type = order[(idx % #order) + 1] state.submission_data.segment_type = next_type state.submission_data.status = "" state.submission_data.status_color = "FFFFFF" render_submission_overlay() end local function submission_set_start() local t = mp.get_property_number("time-pos") if t == nil then state.submission_data.status = "No playback position available" state.submission_data.status_color = "E75C6C" else state.submission_data.start_sec = t state.submission_data.status = "" state.submission_data.status_color = "FFFFFF" end render_submission_overlay() end local function submission_set_end() local t = mp.get_property_number("time-pos") if t == nil then state.submission_data.status = "No playback position available" state.submission_data.status_color = "E75C6C" else state.submission_data.end_sec = t state.submission_data.status = "" state.submission_data.status_color = "FFFFFF" end render_submission_overlay() end local function submission_submit() if state.submission_data.submitting then return end local data = state.submission_data local ok, err = validate_submission_data(data) if not ok then data.status = err or "Invalid submission" data.status_color = "E75C6C" render_submission_overlay() return end if not state.media_info then data.status = "No media information available" data.status_color = "E75C6C" render_submission_overlay() return end if state.media_info.is_tv and (not state.media_info.season or not state.media_info.episode) then data.status = "TV show requires season and episode information" data.status_color = "E75C6C" render_submission_overlay() return end data.submitting = true data.status = "Submitting..." data.status_color = "FFD700" render_submission_overlay() submit_to_all_providers(state.media_info, data, function(results) data.submitting = false local success_count = 0 local total = 0 local messages = {} for provider, res in pairs(results) do total = total + 1 if res.success then success_count = success_count + 1 else table.insert(messages, provider .. ": " .. tostring(res.data)) end end if total == 0 then data.status = "No providers enabled (check API keys)" data.status_color = "E75C6C" elseif success_count == total then data.status = "Submitted successfully to all providers" data.status_color = "7CFC00" else data.status = string.format("Submitted to %d/%d providers", success_count, total) data.status_color = "FFA500" if #messages > 0 then data.status = data.status .. " | " .. table.concat(messages, " | ") end end render_submission_overlay() if success_count == total and total > 0 then if state.submission_status_timer then state.submission_status_timer:kill() end state.submission_status_timer = mp.add_timeout(3.0, function() state.submission_status_timer = nil if state.submission_visible then data.status = "" data.status_color = "FFFFFF" render_submission_overlay() end end) end end) end close_submission_overlay = function() if not state.submission_visible then return end state.submission_visible = false if state.submission_status_timer then state.submission_status_timer:kill() state.submission_status_timer = nil end if state.submission_overlay then state.submission_overlay.data = "" state.submission_overlay:update() end if state.submission_keybinds_active then mp.remove_key_binding("jumpskip_submit_f1") mp.remove_key_binding("jumpskip_submit_f2") mp.remove_key_binding("jumpskip_submit_f") mp.remove_key_binding("jumpskip_submit_enter") mp.remove_key_binding("jumpskip_submit_esc") state.submission_keybinds_active = false end if state.submission_mbtn_bound then mp.remove_key_binding("jumpskip_submit_click") state.submission_mbtn_bound = false end state.submission_hover = nil state.submission_buttons = {} end local function open_submission_overlay() if not user_opts.enable_submission then mp.osd_message("Segment submission is disabled", 2) return end if not state.media_info then state.media_info = extract_media_metadata() end state.submission_visible = true state.submission_data.status = "" state.submission_data.status_color = "FFFFFF" if not state.submission_overlay then state.submission_overlay = mp.create_osd_overlay("ass-events") end if not state.submission_keybinds_active then mp.add_forced_key_binding("F1", "jumpskip_submit_f1", submission_set_start) mp.add_forced_key_binding("F2", "jumpskip_submit_f2", submission_set_end) mp.add_forced_key_binding("f", "jumpskip_submit_f", cycle_submission_segment_type) mp.add_forced_key_binding("ENTER", "jumpskip_submit_enter", submission_submit) mp.add_forced_key_binding("ESC", "jumpskip_submit_esc", close_submission_overlay) state.submission_keybinds_active = true end render_submission_overlay() end toggle_submission_overlay = function() if state.submission_visible then close_submission_overlay() else open_submission_overlay() end end local function submission_render_button(ass, id, x1, y1, x2, y2, label, accent, hovered, selected) local radius = 8 local bg_color = selected and accent or (hovered and "3A3A3A" or "1E1E1E") local border_color = (selected or hovered) and accent or "555555" local text_color = (selected or hovered) and "FFFFFF" or "DDDDDD" ass:new_event() ass:pos(x1, y1) ass:append(string.format("{\\an7\\bord0\\shad0\\1c&H%s&\\alpha&H00&\\p1}", bg_color)) ass:draw_start() ass:round_rect_cw(0, 0, x2 - x1, y2 - y1, radius) ass:draw_stop() ass:new_event() ass:pos(x1, y1) ass:append(string.format("{\\an7\\bord0\\shad0\\1c&H%s&\\alpha&H00&\\p1}", border_color)) ass:draw_start() ass:round_rect_cw(1, 1, (x2 - x1) - 1, (y2 - y1) - 1, radius) ass:draw_stop() local cx = (x1 + x2) / 2 local cy = (y1 + y2) / 2 ass:new_event() ass:pos(cx, cy) ass:append(string.format("{\\an5\\bord0\\shad0\\fs18\\1c&H%s&}", text_color)) ass:append(label) return { id = id, x1 = x1, y1 = y1, x2 = x2, y2 = y2 } end render_submission_overlay = function() if not state.submission_visible then return end if not state.submission_overlay then state.submission_overlay = mp.create_osd_overlay("ass-events") end local w = state.display_w local h = state.display_h local ass = assdraw.ass_new() local box_w = 640 local box_h = 400 local box_x = (w - box_w) / 2 local box_y = (h - box_h) / 2 local bg_alpha = opacity_to_alpha(0.85) local border_alpha = opacity_to_alpha(0.0) ass:new_event() ass:pos(box_x, box_y) ass:append(string.format("{\\an7\\bord0\\shad0\\1c&H%s&\\alpha&H%s&\\p1}", "0A0A0A", bg_alpha)) ass:draw_start() ass:rect_cw(0, 0, box_w, box_h) ass:draw_stop() ass:new_event() ass:pos(box_x, box_y) ass:append(string.format("{\\an7\\bord0\\shad0\\1c&H%s&\\alpha&H%s&\\p1}", "E75C6C", border_alpha)) ass:draw_start() ass:rect_cw(0, 0, box_w, 3) ass:rect_cw(0, box_h - 3, box_w, box_h) ass:rect_cw(0, 0, 3, box_h) ass:rect_cw(box_w - 3, 0, box_w, box_h) ass:draw_stop() local margin = 24 local cx = box_x + box_w / 2 local y = margin local media_info = state.media_info local media_title = media_info and format_media_title(media_info) or "Unknown" local data = state.submission_data local hover = state.submission_hover local buttons = {} local close_btn = 26 table.insert(buttons, submission_render_button(ass, "close", box_x + box_w - margin - close_btn, box_y + margin, box_x + box_w - margin, box_y + margin + close_btn, "✕", "E75C6C", hover == "close")) ass:new_event() ass:pos(cx, box_y + y) ass:append(string.format("{\\an8\\bord0\\shad0\\fs22\\1c&H%s&}", "FFFFFF")) ass:append(media_title) y = y + 30 local id_parts = {} if media_info and media_info.tvdb_id then table.insert(id_parts, "TVDB: " .. media_info.tvdb_id) end if media_info and media_info.imdb_id then table.insert(id_parts, "IMDB: " .. media_info.imdb_id) end if media_info and media_info.tmdb_id then table.insert(id_parts, "TMDB: " .. media_info.tmdb_id) end if #id_parts > 0 then ass:new_event() ass:pos(cx, box_y + y) ass:append(string.format("{\\an8\\bord0\\shad0\\fs16\\1c&H%s&}", "888888")) ass:append(table.concat(id_parts, " ")) y = y + 26 end ass:new_event() ass:pos(box_x, box_y + y) ass:append(string.format("{\\an7\\bord0\\shad0\\1c&H%s&\\alpha&H00&\\p1}", "333333")) ass:draw_start() ass:rect_cw(0, 0, box_w, 1) ass:draw_stop() y = y + 24 ass:new_event() ass:pos(cx, box_y + y) ass:append(string.format("{\\an8\\bord0\\shad0\\fs18\\1c&H%s&}", "AAAAAA")) ass:append("Segment Type (F)") y = y + 34 local seg_btn_h = 30 local seg_gap = 10 local seg_btn_w = 150 local row1_types = { "intro", "recap", "outro" } local row1_w = #row1_types * seg_btn_w + (#row1_types - 1) * seg_gap local row1_x = cx - row1_w / 2 local seg_btn_y1 = box_y + y local seg_btn_y2 = seg_btn_y1 + seg_btn_h for i, seg_type in ipairs(row1_types) do local seg_x1 = row1_x + (i - 1) * (seg_btn_w + seg_gap) local seg_x2 = seg_x1 + seg_btn_w local is_selected = (data.segment_type == seg_type) local is_hovered = (hover == ("seg_" .. seg_type)) table.insert(buttons, submission_render_button(ass, "seg_" .. seg_type, seg_x1, seg_btn_y1, seg_x2, seg_btn_y2, SUBMISSION_TYPE_LABELS[seg_type] or seg_type, "E75C6C", is_hovered, is_selected)) end y = y + seg_btn_h + 10 local row2_types = { "preview", "post_credits" } local row2_w = #row2_types * seg_btn_w + (#row2_types - 1) * seg_gap local row2_x = cx - row2_w / 2 local seg_btn_y1 = box_y + y local seg_btn_y2 = seg_btn_y1 + seg_btn_h for i, seg_type in ipairs(row2_types) do local seg_x1 = row2_x + (i - 1) * (seg_btn_w + seg_gap) local seg_x2 = seg_x1 + seg_btn_w local is_selected = (data.segment_type == seg_type) local is_hovered = (hover == ("seg_" .. seg_type)) table.insert(buttons, submission_render_button(ass, "seg_" .. seg_type, seg_x1, seg_btn_y1, seg_x2, seg_btn_y2, SUBMISSION_TYPE_LABELS[seg_type] or seg_type, "E75C6C", is_hovered, is_selected)) end y = y + seg_btn_h + 24 local time_btn_w = 210 local time_btn_h = 34 local time_gap = 20 local time_row_w = 2 * time_btn_w + time_gap local time_row_x = cx - time_row_w / 2 local time_btn_y1 = box_y + y local time_btn_y2 = time_btn_y1 + time_btn_h table.insert(buttons, submission_render_button(ass, "set_start", time_row_x, time_btn_y1, time_row_x + time_btn_w, time_btn_y2, "Set Start Time (F1)", "4C9AFF", hover == "set_start")) table.insert(buttons, submission_render_button(ass, "set_end", time_row_x + time_btn_w + time_gap, time_btn_y1, time_row_x + 2 * time_btn_w + time_gap, time_btn_y2, "Set End Time (F2)", "4C9AFF", hover == "set_end")) y = y + time_btn_h + 8 local start_cx = time_row_x + time_btn_w / 2 local end_cx = time_row_x + time_btn_w + time_gap + time_btn_w / 2 ass:new_event() ass:pos(start_cx, box_y + y) ass:append(string.format("{\\an8\\bord0\\shad0\\fs18\\1c&H%s&}", "FFFFFF")) ass:append(format_submission_time(data.start_sec)) ass:new_event() ass:pos(end_cx, box_y + y) ass:append(string.format("{\\an8\\bord0\\shad0\\fs18\\1c&H%s&}", "FFFFFF")) ass:append(format_submission_time(data.end_sec)) y = y + 30 local submit_w = 200 local submit_h = 36 local submit_x1 = cx - submit_w / 2 local submit_y1 = box_y + y table.insert(buttons, submission_render_button(ass, "submit", submit_x1, submit_y1, submit_x1 + submit_w, submit_y1 + submit_h, "Submit", "2ECC71", hover == "submit")) y = y + submit_h + 20 if data.status and #data.status > 0 then ass:new_event() ass:pos(cx, box_y + y) ass:append(string.format("{\\an8\\bord0\\shad0\\fs16\\1c&H%s&}", data.status_color)) ass:append(data.status) end state.submission_buttons = buttons state.submission_overlay.res_x = state.display_w state.submission_overlay.res_y = state.display_h state.submission_overlay.data = ass.text state.submission_overlay:update() end local function submission_handle_click() if not state.submission_visible then return end local id = state.submission_hover if not id then return end if id == "set_start" then submission_set_start() elseif id == "set_end" then submission_set_end() elseif id == "submit" then submission_submit() elseif id == "close" then close_submission_overlay() elseif id:sub(1, 4) == "seg_" then local seg_type = id:sub(5) if state.submission_data.segment_type ~= seg_type then state.submission_data.segment_type = seg_type render_submission_overlay() end end end local function submission_set_mbtn_binding(enable) if enable and not state.submission_mbtn_bound then mp.add_forced_key_binding("MBTN_LEFT", "jumpskip_submit_click", submission_handle_click) state.submission_mbtn_bound = true elseif not enable and state.submission_mbtn_bound then mp.remove_key_binding("jumpskip_submit_click") state.submission_mbtn_bound = false end end local function submission_handle_mouse(x, y) if not state.submission_visible then if state.submission_hover then state.submission_hover = nil submission_set_mbtn_binding(false) end return end local hovered = nil for _, btn in ipairs(state.submission_buttons) do if x >= btn.x1 and x <= btn.x2 and y >= btn.y1 and y <= btn.y2 then hovered = btn.id break end end if hovered ~= state.submission_hover then state.submission_hover = hovered submission_set_mbtn_binding(hovered ~= nil) render_submission_overlay() end end local function log_segments_summary(segments, providers_raw) local sep = string.rep("-", 60) if providers_raw and #providers_raw > 0 then mp.msg.info("[jumpskip] " .. sep) mp.msg.info("[jumpskip] SEGMENT QUERY RESULTS (per provider)") mp.msg.info("[jumpskip] " .. sep) for _, prov in ipairs(providers_raw) do local pname = prov.name local psegs = prov.segs if not psegs or #psegs == 0 then local reason = prov.err and (" (" .. prov.err .. ")") or " (no segments)" mp.msg.info(string.format("[jumpskip] %-16s%s", pname, reason)) else mp.msg.info(string.format("[jumpskip] %-16s %d segment(s):", pname, #psegs)) for _, s in ipairs(psegs) do mp.msg.info(string.format("[jumpskip] %-10s %7.2fs (%s) -> %7.2fs (%s)", s.label, s.start_sec, format_hms(s.start_sec), s.end_sec, format_hms(s.end_sec))) end end end mp.msg.info("[jumpskip] " .. sep) end if not segments or #segments == 0 then mp.msg.info("[jumpskip] FINAL SEGMENTS: none") mp.msg.info("[jumpskip] " .. sep) return end mp.msg.info(string.format("[jumpskip] FINAL SEGMENTS: %d total", #segments)) mp.msg.info("[jumpskip] " .. sep) mp.msg.info(string.format("[jumpskip] %-10s %-16s %-20s %-20s", "Type", "Provider", "Start", "End")) mp.msg.info("[jumpskip] " .. string.rep("-", 74)) for _, s in ipairs(segments) do mp.msg.info(string.format("[jumpskip] %-10s %-16s %-20s %-20s", s.label, s.provider, string.format("%.2fs (%s)", s.start_sec, format_hms(s.start_sec)), string.format("%.2fs (%s)", s.end_sec, format_hms(s.end_sec)))) end mp.msg.info("[jumpskip] " .. sep) end local function has_segment_type(segments_list, seg_type) for _, seg in ipairs(segments_list) do if seg.type == seg_type then return true end end return false end local function merge_segment_lists(primary, secondary) local result = {} for _, s in ipairs(primary) do table.insert(result, s) end for _, s in ipairs(secondary) do if not has_segment_type(result, s.type) then table.insert(result, s) log_info("Merged missing '%s' segment from %s into final segments list", s.label, s.provider) end end table.sort(result, function(a, b) return a.start_sec < b.start_sec end) return result end local function segment_marker_enabled(seg_type) if not user_opts.show_colored_segments then return false end local per_type = user_opts["show_colored_" .. tostring(seg_type) .. "_segments"] if per_type == nil then return true end return per_type end local function chapter_title_for_segment(seg_type) local title = tostring(seg_type):gsub("_", " "):gsub("^%l", string.upper) if seg_type == "outro" then local mi = state.media_info if mi and not mi.is_tv then title = "Credits" end end return title end local function build_marked_chapters(base_chapters, segments, duration, default_title) local EPSILON = 0.05 default_title = (default_title and default_title ~= "") and default_title or "Chapter" duration = tonumber(duration) or 0 local original_chapters = {} if type(base_chapters) == "table" then for _, ch in ipairs(base_chapters) do if ch and ch.time then table.insert(original_chapters, { time = tonumber(ch.time) or 0, title = ch.title or "", }) end end end table.sort(original_chapters, function(a, b) return a.time < b.time end) local function get_original_chapter_title_at(time_pos) if #original_chapters == 0 then return nil end local matched = nil for _, ch in ipairs(original_chapters) do if ch.time <= time_pos + EPSILON then matched = ch.title else break end end return matched end local active_segs = {} if type(segments) == "table" then for _, seg in ipairs(segments) do if segment_marker_enabled(seg.type) then local s = math.max(0, tonumber(seg.start_sec) or 0) local e = tonumber(seg.end_sec) or 0 if duration > 0 and e > duration then e = duration end if e > s + EPSILON then table.insert(active_segs, { start_sec = s, end_sec = e, type = seg.type, title = chapter_title_for_segment(seg.type), }) end end end end table.sort(active_segs, function(a, b) return a.start_sec < b.start_sec end) if #active_segs == 0 then return original_chapters, 0 end local function is_inside_any_segment(t) for _, s in ipairs(active_segs) do if (s.start_sec + EPSILON) <= t and t < (s.end_sec - EPSILON) then return true end end return false end local function another_seg_starts_at(t, current_seg) for _, s in ipairs(active_segs) do if s ~= current_seg and math.abs(s.start_sec - t) < EPSILON then return true end end return false end local function original_chapter_starts_at(t) for _, ch in ipairs(original_chapters) do if math.abs(ch.time - t) < EPSILON then return true end end return false end local function segment_starts_at(t) for _, s in ipairs(active_segs) do if math.abs(s.start_sec - t) < EPSILON then return s end end return nil end local candidate_chapters = {} for _, ch in ipairs(original_chapters) do local t = ch.time if not segment_starts_at(t) and not is_inside_any_segment(t) then table.insert(candidate_chapters, { time = t, title = ch.title, source = "base", prio = 2, }) end end for _, seg in ipairs(active_segs) do table.insert(candidate_chapters, { time = seg.start_sec, title = seg.title, source = "seg_start", prio = 3, }) local e = seg.end_sec local skip_closing = false if duration > 0 and e >= duration - EPSILON then skip_closing = true elseif is_inside_any_segment(e) then skip_closing = true elseif another_seg_starts_at(e, seg) then skip_closing = true elseif original_chapter_starts_at(e) then skip_closing = true end if not skip_closing then local orig_title = get_original_chapter_title_at(e) local closing_title = (orig_title and orig_title ~= "") and orig_title or default_title table.insert(candidate_chapters, { time = e, title = closing_title, source = "seg_end", prio = 1, }) end end table.sort(candidate_chapters, function(a, b) if math.abs(a.time - b.time) < EPSILON then return a.prio > b.prio end return a.time < b.time end) local deduped = {} for _, ch in ipairs(candidate_chapters) do local t = math.max(0, ch.time) if not (duration > 0 and t >= duration - EPSILON) then if #deduped == 0 then table.insert(deduped, { time = t, title = ch.title }) else local last = deduped[#deduped] if math.abs(t - last.time) < EPSILON then else table.insert(deduped, { time = t, title = ch.title }) end end end end return deduped, #active_segs end local function publish_segments(segments) local published = {} for _, seg in ipairs(segments) do if segment_marker_enabled(seg.type) then table.insert(published, { start = seg.start_sec, ["end"] = seg.end_sec, kind = seg.type, }) end end local ok = pcall(mp.set_property_native, "user-data/jumpskip/segments", published) if not ok then log_debug("user-data properties unavailable (mpv < 0.36); seekbar highlights disabled") end if user_opts.mark_chapters then if state.base_chapter_list == nil then state.base_chapter_list = mp.get_property_native("chapter-list") or {} end local file_dur = state.duration if not file_dur or file_dur <= 0 then file_dur = mp.get_property_number("duration") or 0 end local chapters, marked_count = build_marked_chapters( state.base_chapter_list, segments, file_dur, user_opts.default_chapter_title ) mp.set_property_native("chapter-list", chapters) log_debug("Updated chapter markers (%d segment(s) marked, %d total chapters in chapter-list)", marked_count, #chapters) end end local function provider_segment_allowed(provider, seg_type) if not provider or not seg_type then return true end local key = tostring(provider):lower() .. '_' .. tostring(seg_type):lower() .. '_segment' local enabled = user_opts[key] if enabled == nil then log_debug('No per-provider toggle %s (unknown provider or segment type); allowing segment', key) return true end return enabled == true end local function filter_provider_segments(segs) local filtered = {} for _, seg in ipairs(segs) do if provider_segment_allowed(seg.provider, seg.type) then table.insert(filtered, seg) else log_info('Dropping %s segment from %s (disabled via %s_%s_segment=no)', tostring(seg.type), tostring(seg.provider), tostring(seg.provider):lower(), tostring(seg.type):lower()) end end return filtered end local function get_provider_priority(media_info) local priority_str if media_info.is_tv then priority_str = user_opts.provider_priority_tvshow else priority_str = user_opts.provider_priority_movie end return parse_provider_priority(priority_str) end local function query_segments_pipeline() if not user_opts.enabled then return end local current_file_id = state.file_id state.query_in_progress = true state.segments_loaded = false state.segments = {} local media_info = state.media_info if not media_info then return end local providers = get_provider_priority(media_info) if #providers == 0 then state.segments_loaded = true state.query_in_progress = false log_info("No providers configured for %s; skipping segment query.", media_info.is_tv and "tvshow" or "movie") return end local media_type = media_info.is_tv and "tvshow" or "movie" mp.commandv("script-message", "jumpskip-provider-order", media_type, table.concat(providers, ",")) log_info("Querying segment timestamps from %d provider(s) [%s]: %s...", #providers, media_type, table.concat(providers, " -> ")) resolve_title_to_imdb(media_info, current_file_id, function(id_found) if state.file_id ~= current_file_id then return end local results = {} local pending = #providers for idx, pname in ipairs(providers) do local query_fn = provider_fns[pname] if not query_fn then pending = pending - 1 results[idx] = { name = pname, ok = false, segs = {}, err = "Unknown provider: " .. pname } else query_fn(media_info, current_file_id, function(ok, segs, err) if state.file_id ~= current_file_id then return end results[idx] = { name = pname, ok = ok, segs = (ok and segs) and filter_provider_segments(segs) or {}, err = err, } pending = pending - 1 if pending == 0 then local providers_raw = {} for i = 1, #providers do table.insert(providers_raw, results[i]) end local final_segs = {} if not user_opts.merge_providers then for i = 1, #providers do if results[i].ok and #results[i].segs > 0 then final_segs = results[i].segs break end end else for i = 1, #providers do if results[i].ok and #results[i].segs > 0 then if #final_segs == 0 then final_segs = results[i].segs else final_segs = merge_segment_lists(final_segs, results[i].segs) end end end end if user_opts.chapter_skip_enabled then local base_chapters = state.base_chapter_list or mp.get_property_native("chapter-list") or {} local chapter_segs = detect_chapter_segments(base_chapters, state.duration, final_segs, state.media_info) if #chapter_segs > 0 then log_info("Adding %d chapter-derived segment(s) to fill provider gaps", #chapter_segs) for _, seg in ipairs(chapter_segs) do table.insert(final_segs, seg) end table.sort(final_segs, function(a, b) return a.start_sec < b.start_sec end) end end state.segments = final_segs state.segments_loaded = true state.query_in_progress = false log_segments_summary(state.segments, providers_raw) publish_segments(final_segs) end end) end end if pending == 0 then state.segments_loaded = true state.query_in_progress = false log_segments_summary({}, {}) end end) end local function update_button_coordinates() local w = state.display_w local h = state.display_h local scale = h / 1080.0 local btn_w = user_opts.button_width * scale local btn_h = user_opts.button_height * scale local margin_x = user_opts.button_margin_x * scale local margin_y = user_opts.button_margin_y * scale local x1, y1, x2, y2 if user_opts.button_position == "bottom-left" then x1, y1 = margin_x, h - margin_y - btn_h x2, y2 = margin_x + btn_w, h - margin_y elseif user_opts.button_position == "top-right" then x1, y1 = w - margin_x - btn_w, margin_y x2, y2 = w - margin_x, margin_y + btn_h elseif user_opts.button_position == "top-left" then x1, y1 = margin_x, margin_y x2, y2 = margin_x + btn_w, margin_y + btn_h else x1, y1 = w - margin_x - btn_w, h - margin_y - btn_h x2, y2 = w - margin_x, h - margin_y end state.box.x1 = x1 state.box.y1 = y1 state.box.x2 = x2 state.box.y2 = y2 end local function render_skip_button() if not state.button_visible or not state.active_segment then if state.overlay then state.overlay.data = "" state.overlay:update() end return end if not state.overlay then state.overlay = mp.create_osd_overlay("ass-events") end update_button_coordinates() local seg = state.active_segment local scale = state.display_h / 1080.0 local x1, y1, x2, y2 = state.box.x1, state.box.y1, state.box.x2, state.box.y2 local center_x = round((x1 + x2) / 2) local center_y = round((y1 + y2) / 2) local radius = round(12 * scale) local ass = assdraw.ass_new() local is_hover = state.mouse_hover local bg_opacity = is_hover and 0.95 or 0.85 local border_alpha = is_hover and "00" or "40" local border_width = is_hover and round(2.0 * scale) or round(1.2 * scale) local glow_opacity = is_hover and 0.35 or 0.15 ass:new_event() ass:append(string.format("{\\pos(0,0)\\rDefault\\an7\\blur%d\\bord0\\1c&H%s&\\1a&H%02X&}", round(8 * scale), user_opts.accent_color, opacity_to_alpha(glow_opacity))) ass:draw_start() ass:round_rect_cw(x1 - (3 * scale), y1 - (3 * scale), x2 + (3 * scale), y2 + (3 * scale), radius + (2 * scale)) ass:draw_stop() ass:new_event() ass:append(string.format("{\\pos(0,0)\\rDefault\\an7\\blur0\\bord%d\\1c&H%s&\\1a&H%02X&\\3c&H%s&\\3a&H%s&}", border_width, user_opts.bg_color, opacity_to_alpha(bg_opacity), user_opts.accent_color, border_alpha)) ass:draw_start() ass:round_rect_cw(x1, y1, x2, y2, radius) ass:draw_stop() local main_label = string.format("Skip %s \xe2\x96\xb6", seg.label or "Segment") if state.auto_skip_target_time then local remaining = math.max(0, math.ceil(state.auto_skip_target_time - mp.get_time())) main_label = string.format("Skip %s (%ds)", seg.label or "Segment", remaining) end local font_size = round(user_opts.button_font_size * scale) ass:new_event() ass:append(string.format("{\\pos(%d,%d)\\rDefault\\an5\\fn%s\\fs%d\\b1\\bord0\\shad1\\4c&H000000&\\1c&H%s&}", center_x, center_y, user_opts.button_font, font_size, user_opts.text_color)) ass:append(main_label) state.overlay.res_x = state.display_w state.overlay.res_y = state.display_h state.overlay.data = ass.text state.overlay.z = 2000 state.overlay:update() end local function show_autoskip_notification(label) if not state.overlay then state.overlay = mp.create_osd_overlay("ass-events") end local w = state.display_w local h = state.display_h local scale = h / 1080.0 local fs = math.floor(user_opts.button_font_size * scale) local margin_y = math.floor(user_opts.button_margin_y * scale) local ay = h - margin_y - math.floor(user_opts.button_height * scale) - math.floor(8 * scale) local ax = w / 2 local a_accent = string.format("%02X", 255 - math.floor(0.92 * 255)) local a_bg = string.format("%02X", 255 - math.floor(0.85 * 255)) local ass = string.format( "{\\an8}{\\pos(%d,%d)}{\\bord0}{\\shad2}{\\4c&H%s&}{\\1c&H%s&}{\\alpha&H%s&}" .. "{\\fn%s}{\\fs%d}{\\b1}Auto-skipped: %s", ax, ay, user_opts.bg_color, user_opts.accent_color, a_accent, user_opts.button_font, fs, label) state.overlay.data = ass state.overlay:update() mp.add_timeout(user_opts.osd_message_duration, function() if state.overlay then state.overlay.data = "" state.overlay:update() end end) end perform_skip = function(is_auto) if not state.active_segment then log_debug("perform_skip called but no active segment") return end local seg = state.active_segment local target = seg.end_sec if not target or target <= 0 then log_warn("Cannot skip: segment end time unknown") return end local skip_kind = is_auto and "Auto-skipping" or "Skipping" log_info("%s %s from %.2fs to %.2fs (provider: %s)", skip_kind, seg.label, seg.start_sec, target, seg.provider) seg.skipped = true mp.commandv("seek", target, "absolute", "exact") if user_opts.show_osd_message then if is_auto then show_autoskip_notification(seg.label) else mp.osd_message(string.format("Skipped %s", seg.label), user_opts.osd_message_duration) end end if state.auto_skip_timer then state.auto_skip_timer:kill() state.auto_skip_timer = nil state.auto_skip_target_time = nil end if state.button_timeout_timer then state.button_timeout_timer:kill() state.button_timeout_timer = nil end state.button_visible = false state.active_segment = nil render_skip_button() end local function set_mbtn_binding(enable) if enable and not state.mbtn_bound then mp.add_forced_key_binding("MBTN_LEFT", "jumpskip_click", perform_skip) state.mbtn_bound = true log_debug("MBTN_LEFT skip binding enabled") elseif not enable and state.mbtn_bound then mp.remove_key_binding("jumpskip_click") state.mbtn_bound = false log_debug("MBTN_LEFT skip binding disabled") end end local function should_autoskip(seg_type) if autoskip_types_active then return autoskip_type_set[seg_type] == true end return user_opts.auto_skip end local function check_playback_position(current_time) if not user_opts.enabled or not current_time or not state.segments_loaded then return end local last = state.last_time if last and current_time < last - 1.0 then for _, seg in ipairs(state.segments) do if seg.skipped and current_time < seg.end_sec then seg.skipped = false log_debug("Reset skipped flag for '%s' (backward seek detected)", seg.label) end end end state.last_time = current_time local matched_segment = nil for _, seg in ipairs(state.segments) do if not seg.skipped and current_time >= seg.start_sec and current_time < (seg.end_sec - 0.2) then matched_segment = seg break end end if matched_segment then if state.active_segment ~= matched_segment then state.active_segment = matched_segment state.button_visible = true log_info("Playback entered %s segment (%.2fs (%s) - %.2fs (%s))", matched_segment.label, matched_segment.start_sec, format_hms(matched_segment.start_sec), matched_segment.end_sec, format_hms(matched_segment.end_sec)) if should_autoskip(matched_segment.type) then if user_opts.auto_skip_countdown > 0 then state.auto_skip_target_time = mp.get_time() + user_opts.auto_skip_countdown if state.auto_skip_timer then state.auto_skip_timer:kill() end state.auto_skip_timer = mp.add_periodic_timer(0.25, function() if not state.active_segment then if state.auto_skip_timer then state.auto_skip_timer:kill() end return end if mp.get_time() >= state.auto_skip_target_time then if state.auto_skip_timer then state.auto_skip_timer:kill() end state.auto_skip_timer = nil state.auto_skip_target_time = nil perform_skip(true) else render_skip_button() end end) else perform_skip(true) return end elseif user_opts.skip_button_timeout > 0 then if state.button_timeout_timer then state.button_timeout_timer:kill() end state.button_timeout_timer = mp.add_timeout(user_opts.skip_button_timeout, function() state.button_timeout_timer = nil if state.button_visible then state.button_visible = false state.mouse_hover = false set_mbtn_binding(false) render_skip_button() log_debug("Skip button hidden after %gs timeout (keybind '%s' still active)", user_opts.skip_button_timeout, tostring(user_opts.keybind)) end end) end render_skip_button() end else if state.active_segment then log_debug("Playback exited %s segment", state.active_segment.label) state.active_segment = nil state.button_visible = false set_mbtn_binding(false) if state.auto_skip_timer then state.auto_skip_timer:kill() state.auto_skip_timer = nil state.auto_skip_target_time = nil end if state.button_timeout_timer then state.button_timeout_timer:kill() state.button_timeout_timer = nil end render_skip_button() end end end local function on_mouse_pos(_, mouse) if not mouse then return end local x, y = mouse.x, mouse.y if state.submission_visible then submission_handle_mouse(x, y) if state.mouse_hover then state.mouse_hover = false set_mbtn_binding(false) end return end if not state.button_visible then if state.mouse_hover then state.mouse_hover = false set_mbtn_binding(false) render_skip_button() end return end local is_inside = (x >= state.box.x1 and x <= state.box.x2 and y >= state.box.y1 and y <= state.box.y2) if is_inside ~= state.mouse_hover then state.mouse_hover = is_inside set_mbtn_binding(is_inside) render_skip_button() end end local function reset_state() state.file_id = state.file_id + 1 state.active_segment = nil state.button_visible = false state.mouse_hover = false state.segments_loaded = false state.segments = {} state.base_chapter_list = nil set_mbtn_binding(false) if state.submission_visible then close_submission_overlay() end if state.submission_status_timer then state.submission_status_timer:kill() state.submission_status_timer = nil end state.submission_data.segment_type = "intro" state.submission_data.start_sec = nil state.submission_data.end_sec = nil state.submission_data.status = "" state.submission_data.status_color = "FFFFFF" state.submission_data.submitting = false if state.auto_skip_timer then state.auto_skip_timer:kill() state.auto_skip_timer = nil end if state.button_timeout_timer then state.button_timeout_timer:kill() state.button_timeout_timer = nil end if state.overlay then state.overlay.data = "" state.overlay:update() end pcall(function() mp.del_property("user-data/jumpskip/segments") end) end local function url_decode(s) if not s then return s end return (s:gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end)) end local function normalize_absolute_path(path) if not path or #path == 0 then return nil end local clean = path:gsub("^file://localhost", ""):gsub("^file://", "") clean = url_decode(clean) clean = clean:gsub("^/([a-zA-Z]:)", "%1") local norm = nil pcall(function() norm = mp.command_native({"normalize-path", clean}) end) if not norm or #norm == 0 then local is_abs = clean:match("^[a-zA-Z]:") or clean:match("^[\\/]") if not is_abs then local work_dir = mp.get_property("working-directory") if not work_dir or #work_dir == 0 then pcall(function() work_dir = utils.getcwd() end) end if work_dir and #work_dir > 0 then clean = utils.join_path(work_dir, clean) end end norm = clean end norm = norm:gsub("\\", "/"):gsub("^/([a-zA-Z]:)", "%1") return norm:lower() end local function is_directory_allowed(path) local filter = user_opts.filter_directories or "" if #filter == 0 then return true end if not path or #path == 0 then return false, "empty path" end local norm = normalize_absolute_path(path) if not norm then return false, "failed to normalize path" end local dir = norm:match("^(.*)/[^/]*$") local folder = nil if dir then folder = dir:match("([^/]+)/?$") if folder and folder:match("^[a-zA-Z]:$") then folder = nil end end local wrapped_dir = nil if dir and #dir > 0 then local trimmed_dir = dir:gsub("^/+", ""):gsub("/+$", "") wrapped_dir = "/" .. trimmed_dir .. "/" end local exclusions = {} local inclusions = {} for entry in filter:gmatch("[^,]+") do local raw = entry:match("^%s*(.-)%s*$") if raw and #raw > 0 then local is_exclusion = raw:find("%*") ~= nil local is_fragment = raw:find("[\\/]") ~= nil local clean = raw:gsub("%*", "") if is_fragment then clean = clean:gsub("^[\\/]+", ""):gsub("\\", "/"):gsub("/+$", ""):match("^%s*(.-)%s*$"):lower() if #clean > 0 then local rule = { mode = "fragment", pattern = clean, raw = raw } if is_exclusion then exclusions[#exclusions + 1] = rule else inclusions[#inclusions + 1] = rule end end else clean = clean:match("^%s*(.-)%s*$"):lower() if #clean > 0 then local rule = { mode = "parent", pattern = clean, raw = raw } if is_exclusion then exclusions[#exclusions + 1] = rule else inclusions[#inclusions + 1] = rule end end end end end local function rule_matches(rule) if rule.mode == "parent" then return folder and (folder == rule.pattern) elseif rule.mode == "fragment" then return wrapped_dir and string.find(wrapped_dir, "/" .. rule.pattern .. "/", 1, true) ~= nil end return false end for _, rule in ipairs(exclusions) do if rule_matches(rule) then log_debug("filter_directories: file '%s' matched exclusion rule '%s' -> skipping", path, rule.raw) return false, "matched exclusion rule '" .. rule.raw .. "'" end end if #inclusions == 0 then return true end for _, rule in ipairs(inclusions) do if rule_matches(rule) then log_debug("filter_directories: file '%s' matched inclusion rule '%s' -> allowed", path, rule.raw) return true end end log_debug("filter_directories: file '%s' did not match any inclusion rules -> skipping", path) return false, "folder not in filter_directories inclusions" end local function on_file_loaded() load_configuration() reset_state() state.path = mp.get_property("path") state.filename = mp.get_property("filename") local allowed, reason = is_directory_allowed(state.path) if not allowed then log_debug("Skipping file (%s): %s", reason or "excluded by filter_directories", state.path or "(none)") return end state.duration = mp.get_property_number("duration") or 0 state.media_info = extract_media_metadata() query_segments_pipeline() end local function on_end_file() reset_state() end local function on_osd_dimensions_change(_, dim) if dim and dim.w and dim.h and dim.w > 0 and dim.h > 0 then state.display_w = dim.w state.display_h = dim.h if state.button_visible then render_skip_button() end if state.submission_visible then render_submission_overlay() end end end mp.register_script_message("skip-segment", perform_skip) mp.register_script_message("reload-segments", function() log_info("Reloading segment timestamps...") query_segments_pipeline() end) mp.register_script_message("reload-config", function() load_configuration() if state.path then query_segments_pipeline() end end) mp.observe_property("time-pos", "number", function(_, cur_time) check_playback_position(cur_time) end) mp.observe_property("osd-dimensions", "native", on_osd_dimensions_change) mp.observe_property("mouse-pos", "native", on_mouse_pos) mp.register_event("file-loaded", on_file_loaded) mp.register_event("end-file", on_end_file) load_configuration()