local utils = require "mp.utils" local opts = require "mp.options" local bit = require "bit" -- https://wiki.sponsor.ajay.app/w/Types#Category -- See README to configure options local options = { api_url = "https://sponsor.ajay.app/api", categories = "sponsor interaction music_offtopic exclusive_access", whitelisted_channels = "", find_id_from_filename = true, find_id_from_title = false } local disabled = false local video_data = nil local fvl_pretty = { sponsor = "is sponsored", selfpromo = "is a self-promotion", interaction = "is an interaction reminder", intro = "is an intro", outro = "is an outro", preview = "is a preview", music_offtopic = "is offtopic content", filler = "is filler", exclusive_access = "features exclusive access" } local category_pretty = { sponsor = "sponsor", selfpromo = "self-promotion", interaction = "interaction reminder", intro = "intro", outro = "outro", preview = "preview", music_offtopic = "offtopic content", filler = "filler" } function startFile() local ids = getIDs() if ids == nil then return end if ids.channel_id ~= nil and inTableValue(split(options.whitelisted_channels), ids.channel_id) then return end video_data = getSegments(ids.video_id) if video_data == nil or disabled then return end for _, segment in pairs(video_data.segments) do if segment.actionType == "full" and inTableValue(split(options.categories), segment.category) then local message = "This entire video " if fvl_pretty[segment.category] ~= nil then message = message .. fvl_pretty[segment.category] else message = message .. "is " .. segment.category end mp.msg.info(message) mp.osd_message(message, 5) break end end end function observePlaybackTime(_, current_pos) if video_data == nil or current_pos == nil or disabled then return end for _, segment in pairs(video_data.segments) do local seek_offset = segment.segment[2] - current_pos if current_pos >= segment.segment[1] and segment.segment[2] > current_pos and seek_offset >= 0.01 and segment.category ~= "full" and inTableValue(split(options.categories), segment.category) then local category_name = segment.category if category_pretty[category_name] ~= nil then category_name = category_pretty[category_name] end if segment.actionType == "skip" then mp.osd_message("[sponsorblock] Skipping " .. seek_offset .. "s " .. category_name) mp.set_property_number("playback-time", segment.segment[2]) current_pos = segment.segment[2] elseif segment.actionType == "mute" then -- if mute is false, then it's not muted, which causes it to be muted local caused_mute = mp.get_property_bool("mute") == false -- if subs are visible, they'll be hidden local caused_hidden_subs = mp.get_property_bool("sub-visibility") == true if (caused_mute or caused_hidden_subs) and (video_data.current_mute_segment == nil or segment.segment[1] ~= video_data.current_mute_segment.segment[1] or segment.segment[2] ~= video_data.current_mute_segment.segment[2]) then mp.osd_message("[sponsorblock] Muting " .. segment.segment[2] - current_pos .. "s " .. category_name) video_data.current_mute_segment = { segment = segment.segment, caused_mute = caused_mute, caused_hidden_subs = caused_hidden_subs } if caused_mute then mp.set_property_bool("mute", true) end if caused_hidden_subs then mp.set_property_bool("sub-visibility", false) end end else mp.msg.warn("Unknown action type:", segment.actionType) end end end if video_data.current_mute_segment ~= nil and (current_pos < video_data.current_mute_segment.segment[1] or video_data.current_mute_segment.segment[2] <= current_pos) then if video_data.current_mute_segment.caused_mute then mp.set_property_bool("mute", false) end if video_data.current_mute_segment.caused_hidden_subs then mp.set_property_bool("sub-visibility", true) end video_data.current_mute_segment = nil end end function endFile() if video_data ~= nil and video_data.current_mute_segment ~= nil then if video_data.current_mute_segment.caused_mute then mp.set_property_bool("mute", false) end if video_data.current_mute_segment.caused_hidden_subs then mp.set_property_bool("sub-visibility", true) end end video_data = nil end function toggleSponsorblock() local message = "[sponsorblock] " if disabled then disabled = false message = message .. "Enabled" else disabled = true if video_data ~= nil and video_data.current_mute_segment ~= nil then if video_data.current_mute_segment.caused_mute then mp.set_property_bool("mute", false) end if video_data.current_mute_segment.caused_hidden_subs then mp.set_property_bool("sub-visibility", true) end video_data.current_mute_segment = nil end message = message .. "Disabled" end local segments_available = 0 if video_data ~= nil then for _, i in pairs(video_data.segments) do if i.actionType ~= "full" and inTableValue(split(options.categories), i.category) then segments_available = segments_available + 1 end end end if segments_available == 0 then message = message .. " (but no segments available)" end mp.osd_message(message) end function getSegments(video_id) local proc = mp.command_native({ name = "subprocess", args = {"sha256sum"}, playback_only = true, capture_stdout = true, capture_stderr = false, stdin_data = video_id }) if proc.killed_by_us then return end if proc.status ~= 0 then mp.msg.error("Failed to hash video id: process exited with", proc.status) return end local hashed_id = string.lower(string.sub(proc.stdout, 0, 4)) if string.len(hashed_id) ~= 4 then mp.msg.error("Failed to hash video id: hashed id length is not 4:", proc.stdout) return end if string.match(hashed_id, "[^0-9a-f]") then mp.msg.error("Failed to hash video id: hashed id is not hex:", proc.stdout) return end local url = options.api_url .. "/skipSegments/" .. hashed_id .. "?actionTypes=" .. percentEncodeComponent("[\"skip\",\"mute\",\"full\"]") .. "&categories=" .. percentEncodeComponent(utils.format_json(split(options.categories))) proc = mp.command_native({ name = "subprocess", args = {"curl", "--silent", "--show-error", "--globoff", url}, playback_only = true, capture_stdout = true, capture_stderr = false }) if proc.killed_by_us then return end if proc.status ~= 0 then mp.msg.error("Failed to retrieve segments: process exited with", proc.status) return end local segments = utils.parse_json(proc.stdout) if segments == nil then mp.msg.error("Failed to parse segments: stdout:", proc.stdout) return end for _, i in pairs(segments) do if i.videoID == video_id then return i end end end function getIDs() local path = mp.get_property("path") if path == nil then mp.msg.error("Failed to get path") path = "" end local title = mp.get_property("media-title") if title == nil then mp.msg.error("Failed to get title") path = "" end local result = { video_id = nil, channel_id = nil } path = string.gsub(path, "^https?://", "") path = string.gsub(path, "^www%.", "") result.video_id = string.match(path, "^youtu%.be/([%w%-_]+)") if result.video_id ~= nil and string.len(result.video_id) >= 11 then return result end result.video_id = string.match(path, "^youtube%.com/watch%?v=([%w%-_]+)") if result.video_id ~= nil and string.len(result.video_id) >= 11 then return result end result.video_id = string.match(path, "^youtube%.com/embed/([%w%-_]+)") if result.video_id ~= nil and string.len(result.video_id) >= 11 then return result end result.video_id = string.match(path, "^youtube-nocookies?%.com/embed/([%w%-_]+)") if result.video_id ~= nil and string.len(result.video_id) >= 11 then return result end if options.find_id_from_filename then -- Regex exists. result.video_id = string.match(path, ".+-([%w%-_][%w%-_][%w%-_][%w%-_][%w%-_][%w%-_][%w%-_][%w%-_][%w%-_][%w%-_][%w%-_]+)%.%w%w%w%w?$") if result.video_id ~= nil then return result end end if options.find_id_from_title then for video_id, channel_id in string.gmatch(title, ".+ %(([%w%-_]+) by ([%w%-_]+)%)$") do if string.len(video_id) >= 11 and string.len(channel_id) >= 24 then result.video_id = video_id result.channel_id = channel_id return result end end end end function percentEncodeComponent(s) -- https://url.spec.whatwg.org/#component-percent-encode-set return string.gsub(s, "[^!'()*-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~]", function (char) local byte = string.byte(char) return "%" .. bit.tohex(byte, -2) end) end function split(s) local arr = {} for i in string.gmatch(s, "%S+") do table.insert(arr, i) end return arr end function inTableValue(tab, item) for _, i in pairs(tab) do if i == item then return true end end return false end opts.read_options(options, "sponsorblock_lm") mp.add_key_binding("b", "toggle", toggleSponsorblock) mp.register_event("start-file", startFile) mp.observe_property("playback-time", "number", observePlaybackTime) mp.register_event("end-file", endFile)