sponsorblock.lua (21266B)
1 -- GPLv3 2 3 -- sponsorblock.lua 4 -- 5 -- This script skips sponsored segments of YouTube videos 6 -- using data from https://github.com/ajayyy/SponsorBlock 7 8 local ON_WINDOWS = package.config:sub(1,1) ~= "/" 9 10 local options = { 11 server_address = "https://sponsor.ajay.app", 12 13 python_path = ON_WINDOWS and "python" or "python3", 14 15 -- Categories to fetch 16 categories = "sponsor,intro,outro,interaction,selfpromo", 17 18 -- Categories to skip automatically 19 skip_categories = "sponsor", 20 21 -- If true, sponsored segments will only be skipped once 22 skip_once = true, 23 24 -- Note that sponsored segments may ocasionally be inaccurate if this is turned off 25 -- see https://blog.ajay.app/voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker 26 local_database = false, 27 28 -- Update database on first run, does nothing if local_database is false 29 auto_update = false, 30 31 -- How long to wait between local database updates 32 -- Format: "X[d,h,m]", leave blank to update on every mpv run 33 auto_update_interval = "6h", 34 35 -- User ID used to submit sponsored segments, leave blank for random 36 user_id = "", 37 38 -- Name to display on the stats page https://sponsor.ajay.app/stats/ leave blank to keep current name 39 display_name = "", 40 41 -- Tell the server when a skip happens 42 report_views = true, 43 44 -- Auto upvote skipped sponsors 45 auto_upvote = false, 46 47 -- Use sponsor times from server if they're more up to date than our local database 48 server_fallback = true, 49 50 -- Create chapters at sponsor boundaries for OSC display and manual skipping 51 make_chapters = true, 52 53 -- Minimum duration for sponsors (in seconds), segments under that threshold will be ignored 54 min_duration = 1, 55 56 -- Fade audio for smoother transitions 57 audio_fade = false, 58 59 -- Audio fade step, applied once every 100ms until cap is reached 60 audio_fade_step = 10, 61 62 -- Audio fade cap 63 audio_fade_cap = 0, 64 65 -- Fast forward through sponsors instead of skipping 66 fast_forward = false, 67 68 -- Playback speed modifier when fast forwarding, applied once every second until cap is reached 69 fast_forward_increase = .2, 70 71 -- Playback speed cap 72 fast_forward_cap = 2, 73 74 -- Length of the sha256 prefix (3-32) when querying server, 0 to disable 75 sha256_length = 4, 76 77 -- Pattern for video id in local files, ignored if blank 78 -- Recommended value for base youtube-dl is "-([%w-_]+)%.[mw][kpe][v4b]m?$" 79 local_pattern = "", 80 81 -- Legacy option, use skip_categories instead 82 skip = true 83 } 84 85 mp.options = require "mp.options" 86 mp.options.read_options(options, "sponsorblock") 87 88 local legacy = mp.command_native_async == nil 89 if legacy then 90 options.local_database = false 91 end 92 93 local utils = require "mp.utils" 94 scripts_dir = mp.find_config_file("scripts") 95 96 local sponsorblock = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.py") 97 local uid_path = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.txt") 98 local database_file = options.local_database and utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.db") or "" 99 local youtube_id = nil 100 local ranges = {} 101 local init = false 102 local segment = {a = 0, b = 0, progress = 0, first = true} 103 local retrying = false 104 local last_skip = {uuid = "", dir = nil} 105 local speed_timer = nil 106 local fade_timer = nil 107 local fade_dir = nil 108 local volume_before = mp.get_property_number("volume") 109 local categories = {} 110 local all_categories = {"sponsor", "intro", "outro", "interaction", "selfpromo", "preview", "music_offtopic"} 111 local chapter_cache = {} 112 113 for category in string.gmatch(options.skip_categories, "([^,]+)") do 114 categories[category] = true 115 end 116 117 function file_exists(name) 118 local f = io.open(name,"r") 119 if f ~= nil then io.close(f) return true else return false end 120 end 121 122 function t_count(t) 123 local count = 0 124 for _ in pairs(t) do count = count + 1 end 125 return count 126 end 127 128 function time_sort(a, b) 129 if a.time == b.time then 130 return string.match(a.title, "segment end") 131 end 132 return a.time < b.time 133 end 134 135 function parse_update_interval() 136 local s = options.auto_update_interval 137 if s == "" then return 0 end -- Interval Disabled 138 139 local num, mod = s:match "^(%d+)([hdm])$" 140 141 if num == nil or mod == nil then 142 mp.osd_message("[sponsorblock] auto_update_interval " .. s .. " is invalid", 5) 143 return nil 144 end 145 146 local time_table = { 147 m = 60, 148 h = 60 * 60, 149 d = 60 * 60 * 24, 150 } 151 152 return num * time_table[mod] 153 end 154 155 function clean_chapters() 156 local chapters = mp.get_property_native("chapter-list") 157 local new_chapters = {} 158 for _, chapter in pairs(chapters) do 159 if chapter.title ~= "Preview segment start" and chapter.title ~= "Preview segment end" then 160 table.insert(new_chapters, chapter) 161 end 162 end 163 mp.set_property_native("chapter-list", new_chapters) 164 end 165 166 function create_chapter(chapter_title, chapter_time) 167 local chapters = mp.get_property_native("chapter-list") 168 local duration = mp.get_property_native("duration") 169 table.insert(chapters, {title=chapter_title, time=(duration == nil or duration > chapter_time) and chapter_time or duration - .001}) 170 table.sort(chapters, time_sort) 171 mp.set_property_native("chapter-list", chapters) 172 end 173 174 function process(uuid, t, new_ranges) 175 start_time = tonumber(string.match(t, "[^,]+")) 176 end_time = tonumber(string.sub(string.match(t, ",[^,]+"), 2)) 177 for o_uuid, o_t in pairs(ranges) do 178 if (start_time >= o_t.start_time and start_time <= o_t.end_time) or (o_t.start_time >= start_time and o_t.start_time <= end_time) then 179 new_ranges[o_uuid] = o_t 180 return 181 end 182 end 183 category = string.match(t, "[^,]+$") 184 if categories[category] and end_time - start_time >= options.min_duration then 185 new_ranges[uuid] = { 186 start_time = start_time, 187 end_time = end_time, 188 category = category, 189 skipped = false 190 } 191 end 192 if options.make_chapters and not chapter_cache[uuid] then 193 chapter_cache[uuid] = true 194 local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) 195 create_chapter(category_title .. " segment start (" .. string.sub(uuid, 1, 6) .. ")", start_time) 196 create_chapter(category_title .. " segment end (" .. string.sub(uuid, 1, 6) .. ")", end_time) 197 end 198 end 199 200 function getranges(_, exists, db, more) 201 if type(exists) == "table" and exists["status"] == "1" then 202 if options.server_fallback then 203 mp.add_timeout(0, function() getranges(true, true, "") end) 204 else 205 return mp.osd_message("[sponsorblock] database update failed, gave up") 206 end 207 end 208 if db ~= "" and db ~= database_file then db = database_file end 209 if exists ~= true and not file_exists(db) then 210 if not retrying then 211 mp.osd_message("[sponsorblock] database update failed, retrying...") 212 retrying = true 213 end 214 return update() 215 end 216 if retrying then 217 mp.osd_message("[sponsorblock] database update succeeded") 218 retrying = false 219 end 220 local sponsors 221 local args = { 222 options.python_path, 223 sponsorblock, 224 "ranges", 225 db, 226 options.server_address, 227 youtube_id, 228 options.categories, 229 tostring(options.sha256_length) 230 } 231 if not legacy then 232 sponsors = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) 233 else 234 sponsors = utils.subprocess({args = args}) 235 end 236 mp.msg.debug("Got: " .. string.gsub(sponsors.stdout, "[\n\r]", "")) 237 if not string.match(sponsors.stdout, "^%s*(.*%S)") then return end 238 if string.match(sponsors.stdout, "error") then return getranges(true, true) end 239 local new_ranges = {} 240 local r_count = 0 241 if more then r_count = -1 end 242 for t in string.gmatch(sponsors.stdout, "[^:%s]+") do 243 uuid = string.match(t, "([^,]+),[^,]+$") 244 if ranges[uuid] then 245 new_ranges[uuid] = ranges[uuid] 246 else 247 process(uuid, t, new_ranges) 248 end 249 r_count = r_count + 1 250 end 251 local c_count = t_count(ranges) 252 if c_count == 0 or r_count >= c_count then 253 ranges = new_ranges 254 end 255 end 256 257 function fast_forward() 258 if options.fast_forward and options.fast_forward == true then 259 speed_timer = nil 260 mp.set_property("speed", 1) 261 end 262 local last_speed = mp.get_property_number("speed") 263 local new_speed = math.min(last_speed + options.fast_forward_increase, options.fast_forward_cap) 264 if new_speed <= last_speed then return end 265 mp.set_property("speed", new_speed) 266 end 267 268 function fade_audio(step) 269 local last_volume = mp.get_property_number("volume") 270 local new_volume = math.max(options.audio_fade_cap, math.min(last_volume + step, volume_before)) 271 if new_volume == last_volume then 272 if step >= 0 then fade_dir = nil end 273 if fade_timer ~= nil then fade_timer:kill() end 274 fade_timer = nil 275 return 276 end 277 mp.set_property("volume", new_volume) 278 end 279 280 function skip_ads(name, pos) 281 if pos == nil then return end 282 local sponsor_ahead = false 283 for uuid, t in pairs(ranges) do 284 if (options.fast_forward == uuid or not options.skip_once or not t.skipped) and t.start_time <= pos and t.end_time > pos then 285 if options.fast_forward == uuid then return end 286 if options.fast_forward == false then 287 mp.osd_message("[sponsorblock] " .. t.category .. " skipped") 288 mp.set_property("time-pos", t.end_time) 289 else 290 mp.osd_message("[sponsorblock] skipping " .. t.category) 291 end 292 t.skipped = true 293 last_skip = {uuid = uuid, dir = nil} 294 if options.report_views or options.auto_upvote then 295 local args = { 296 options.python_path, 297 sponsorblock, 298 "stats", 299 database_file, 300 options.server_address, 301 youtube_id, 302 uuid, 303 options.report_views and "1" or "", 304 uid_path, 305 options.user_id, 306 options.auto_upvote and "1" or "" 307 } 308 if not legacy then 309 mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) 310 else 311 utils.subprocess_detached({args = args}) 312 end 313 end 314 if options.fast_forward ~= false then 315 options.fast_forward = uuid 316 if speed_timer ~= nil then speed_timer:kill() end 317 speed_timer = mp.add_periodic_timer(1, fast_forward) 318 end 319 return 320 elseif (not options.skip_once or not t.skipped) and t.start_time <= pos + 1 and t.end_time > pos + 1 then 321 sponsor_ahead = true 322 end 323 end 324 if options.audio_fade then 325 if sponsor_ahead then 326 if fade_dir ~= false then 327 if fade_dir == nil then volume_before = mp.get_property_number("volume") end 328 if fade_timer ~= nil then fade_timer:kill() end 329 fade_dir = false 330 fade_timer = mp.add_periodic_timer(.1, function() fade_audio(-options.audio_fade_step) end) 331 end 332 elseif fade_dir == false then 333 fade_dir = true 334 if fade_timer ~= nil then fade_timer:kill() end 335 fade_timer = mp.add_periodic_timer(.1, function() fade_audio(options.audio_fade_step) end) 336 end 337 end 338 if options.fast_forward and options.fast_forward ~= true then 339 options.fast_forward = true 340 speed_timer:kill() 341 speed_timer = nil 342 mp.set_property("speed", 1) 343 end 344 end 345 346 function vote(dir) 347 if last_skip.uuid == "" then return mp.osd_message("[sponsorblock] no sponsors skipped, can't submit vote") end 348 local updown = dir == "1" and "up" or "down" 349 if last_skip.dir == dir then return mp.osd_message("[sponsorblock] " .. updown .. "vote already submitted") end 350 last_skip.dir = dir 351 local args = { 352 options.python_path, 353 sponsorblock, 354 "stats", 355 database_file, 356 options.server_address, 357 youtube_id, 358 last_skip.uuid, 359 "", 360 uid_path, 361 options.user_id, 362 dir 363 } 364 if not legacy then 365 mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) 366 else 367 utils.subprocess({args = args}) 368 end 369 mp.osd_message("[sponsorblock] " .. updown .. "vote submitted") 370 end 371 372 function update() 373 mp.command_native_async({name = "subprocess", playback_only = false, args = { 374 options.python_path, 375 sponsorblock, 376 "update", 377 database_file, 378 options.server_address 379 }}, getranges) 380 end 381 382 function file_loaded() 383 local initialized = init 384 ranges = {} 385 segment = {a = 0, b = 0, progress = 0, first = true} 386 last_skip = {uuid = "", dir = nil} 387 chapter_cache = {} 388 local video_path = mp.get_property("path", "") 389 mp.msg.debug("Path: " .. video_path) 390 local video_referer = string.match(mp.get_property("http-header-fields", ""), "Referer:([^,]+)") or "" 391 mp.msg.debug("Referer: " .. video_referer) 392 393 local urls = { 394 "https?://youtu%.be/([%w-_]+).*", 395 "https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*", 396 "/watch.*[?&]v=([%w-_]+).*", 397 "/embed/([%w-_]+).*" 398 } 399 youtube_id = nil 400 for i,url in ipairs(urls) do 401 youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url) 402 end 403 youtube_id = youtube_id or string.match(video_path, options.local_pattern) 404 405 if not youtube_id or string.len(youtube_id) < 11 or (local_pattern and string.len(youtube_id) ~= 11) then return end 406 youtube_id = string.sub(youtube_id, 1, 11) 407 mp.msg.debug("Found YouTube ID: " .. youtube_id) 408 init = true 409 if not options.local_database then 410 getranges(true, true) 411 else 412 local exists = file_exists(database_file) 413 if exists and options.server_fallback then 414 getranges(true, true) 415 mp.add_timeout(0, function() getranges(true, true, "", true) end) 416 elseif exists then 417 getranges(true, true) 418 elseif options.server_fallback then 419 mp.add_timeout(0, function() getranges(true, true, "") end) 420 end 421 end 422 if initialized then return end 423 if options.skip then 424 mp.observe_property("time-pos", "native", skip_ads) 425 end 426 if options.display_name ~= "" then 427 local args = { 428 options.python_path, 429 sponsorblock, 430 "username", 431 database_file, 432 options.server_address, 433 youtube_id, 434 "", 435 "", 436 uid_path, 437 options.user_id, 438 options.display_name 439 } 440 if not legacy then 441 mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) 442 else 443 utils.subprocess_detached({args = args}) 444 end 445 end 446 if not options.local_database or (not options.auto_update and file_exists(database_file)) then return end 447 448 if file_exists(database_file) then 449 local db_info = utils.file_info(database_file) 450 local cur_time = os.time(os.date("*t")) 451 local upd_interval = parse_update_interval() 452 if upd_interval == nil or os.difftime(cur_time, db_info.mtime) < upd_interval then return end 453 end 454 455 update() 456 end 457 458 function set_segment() 459 if not youtube_id then return end 460 local pos = mp.get_property_number("time-pos") 461 if pos == nil then return end 462 if segment.progress > 1 then 463 segment.progress = segment.progress - 2 464 end 465 if segment.progress == 1 then 466 segment.progress = 0 467 segment.b = pos 468 mp.osd_message("[sponsorblock] segment boundary B set, press again for boundary A", 3) 469 else 470 segment.progress = 1 471 segment.a = pos 472 mp.osd_message("[sponsorblock] segment boundary A set, press again for boundary B", 3) 473 end 474 if options.make_chapters and not segment.first then 475 local start_time = math.min(segment.a, segment.b) 476 local end_time = math.max(segment.a, segment.b) 477 if end_time - start_time ~= 0 and end_time ~= 0 then 478 clean_chapters() 479 create_chapter("Preview segment start", start_time) 480 create_chapter("Preview segment end", end_time) 481 end 482 end 483 segment.first = false 484 end 485 486 function select_category(selected) 487 for category in string.gmatch(options.categories, "([^,]+)") do 488 mp.remove_key_binding("select_category_"..category) 489 mp.remove_key_binding("kp_select_category_"..category) 490 end 491 submit_segment(selected) 492 end 493 494 function submit_segment(category) 495 if not youtube_id then return end 496 local start_time = math.min(segment.a, segment.b) 497 local end_time = math.max(segment.a, segment.b) 498 if end_time - start_time == 0 or end_time == 0 then 499 mp.osd_message("[sponsorblock] empty segment, not submitting") 500 elseif segment.progress <= 1 then 501 segment.progress = segment.progress + 2 502 local category_list = "" 503 for category_id, category in pairs(all_categories) do 504 local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) 505 category_list = category_list .. category_id .. ": " .. category_title .. "\n" 506 mp.add_forced_key_binding(tostring(category_id), "select_category_"..category, function() select_category(category) end) 507 mp.add_forced_key_binding("KP"..tostring(category_id), "kp_select_category_"..category, function() select_category(category) end) 508 end 509 mp.osd_message(string.format("[sponsorblock] press a number to select category for segment: %.2d:%.2d:%.2d to %.2d:%.2d:%.2d\n\n" .. category_list .. "\nyou can press Shift+G again for default (Sponsor) or hide this message with g", math.floor(start_time/(60*60)), math.floor(start_time/60%60), math.floor(start_time%60), math.floor(end_time/(60*60)), math.floor(end_time/60%60), math.floor(end_time%60)), 30) 510 else 511 mp.osd_message("[sponsorblock] submitting segment...", 30) 512 local submit 513 local args = { 514 options.python_path, 515 sponsorblock, 516 "submit", 517 database_file, 518 options.server_address, 519 youtube_id, 520 tostring(start_time), 521 tostring(end_time), 522 uid_path, 523 options.user_id, 524 category or "sponsor" 525 } 526 if not legacy then 527 submit = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) 528 else 529 submit = utils.subprocess({args = args}) 530 end 531 if string.match(submit.stdout, "success") then 532 segment = {a = 0, b = 0, progress = 0, first = true} 533 mp.osd_message("[sponsorblock] segment submitted") 534 if options.make_chapters then 535 clean_chapters() 536 create_chapter("Submitted segment start", start_time) 537 create_chapter("Submitted segment end", end_time) 538 end 539 elseif string.match(submit.stdout, "error") then 540 mp.osd_message("[sponsorblock] segment submission failed, server may be down. try again", 5) 541 elseif string.match(submit.stdout, "502") then 542 mp.osd_message("[sponsorblock] segment submission failed, server is down. try again", 5) 543 elseif string.match(submit.stdout, "400") then 544 mp.osd_message("[sponsorblock] segment submission failed, impossible inputs", 5) 545 segment = {a = 0, b = 0, progress = 0, first = true} 546 elseif string.match(submit.stdout, "429") then 547 mp.osd_message("[sponsorblock] segment submission failed, rate limited. try again", 5) 548 elseif string.match(submit.stdout, "409") then 549 mp.osd_message("[sponsorblock] segment already submitted", 3) 550 segment = {a = 0, b = 0, progress = 0, first = true} 551 else 552 mp.osd_message("[sponsorblock] segment submission failed", 5) 553 end 554 end 555 end 556 557 mp.register_event("file-loaded", file_loaded) 558 mp.add_key_binding("g", "set_segment", set_segment) 559 mp.add_key_binding("G", "submit_segment", submit_segment) 560 mp.add_key_binding("$", "upvote_segment", function() return vote("1") end) 561 mp.add_key_binding("%", "downvote_segment", function() return vote("0") end) 562 -- Bindings below are for backwards compatibility and could be removed at any time 563 mp.add_key_binding(nil, "sponsorblock_set_segment", set_segment) 564 mp.add_key_binding(nil, "sponsorblock_submit_segment", submit_segment) 565 mp.add_key_binding(nil, "sponsorblock_upvote", function() return vote("1") end) 566 mp.add_key_binding(nil, "sponsorblock_downvote", function() return vote("0") end)