sponsorblock.lua (15070B)
1 -- sponsorblock.lua 2 -- 3 -- This script skips sponsored segments of YouTube videos 4 -- using data from https://github.com/ajayyy/SponsorBlock 5 6 local ON_WINDOWS = package.config:sub(1,1) ~= '/' 7 8 local options = { 9 server_address = "https://api.sponsor.ajay.app", 10 11 python_path = ON_WINDOWS and "python" or "python3", 12 13 -- If true, sponsored segments will only be skipped once 14 skip_once = true, 15 16 -- Note that sponsored segments may ocasionally be inaccurate if this is turned off 17 -- see https://ajay.app/blog.html#voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker 18 local_database = true, 19 20 -- Update database on first run, does nothing if local_database is false 21 auto_update = true, 22 23 -- User ID used to submit sponsored segments, leave blank for random 24 user_id = "", 25 26 -- Name to display on the stats page https://sponsor.ajay.app/stats/ leave blank to keep current name 27 display_name = "", 28 29 -- Tell the server when a skip happens 30 report_views = true, 31 32 -- Auto upvote skipped sponsors 33 auto_upvote = true, 34 35 -- Use sponsor times from server if they're more up to date than our local database 36 server_fallback = true, 37 38 -- Minimum duration for sponsors (in seconds), segments under that threshold will be ignored 39 min_duration = 1, 40 41 -- Fade audio for smoother transitions 42 audio_fade = false, 43 44 -- Audio fade step, applied once every 100ms until cap is reached 45 audio_fade_step = 10, 46 47 -- Audio fade cap 48 audio_fade_cap = 0, 49 50 -- Fast forward through sponsors instead of skipping 51 fast_forward = false, 52 53 -- Playback speed modifier when fast forwarding, applied once every second until cap is reached 54 fast_forward_increase = .2, 55 56 -- Playback speed cap 57 fast_forward_cap = 2, 58 59 -- Pattern for video id in local files, ignored if blank 60 -- Recommended value for base youtube-dl is "-([%a%d%-_]+)%.[mw][kpe][v4b][m]?$" 61 local_pattern = "" 62 } 63 64 mp.options = require "mp.options" 65 mp.options.read_options(options, "sponsorblock") 66 67 local legacy = mp.command_native_async == nil 68 if legacy then 69 options.local_database = false 70 end 71 72 local utils = require "mp.utils" 73 local scripts_dir = mp.find_config_file("scripts") 74 local sponsorblock = utils.join_path(scripts_dir, "shared/sponsorblock.py") 75 local uid_path = utils.join_path(scripts_dir, "shared/sponsorblock.txt") 76 local database_file = options.local_database and utils.join_path(scripts_dir, "shared/sponsorblock.db") or "" 77 local youtube_id = nil 78 local ranges = {} 79 local init = false 80 local segment = {a = 0, b = 0, progress = 0} 81 local retrying = false 82 local last_skip = {uuid = "", dir = nil} 83 local speed_timer = nil 84 local fade_timer = nil 85 local fade_dir = nil 86 local volume_before = mp.get_property_number("volume") 87 88 function file_exists(name) 89 local f = io.open(name,"r") 90 if f ~= nil then io.close(f) return true else return false end 91 end 92 93 function t_count(t) 94 local count = 0 95 for _ in pairs(t) do count = count + 1 end 96 return count 97 end 98 99 function getranges(_, exists, db, more) 100 if type(exists) == "table" and exists["status"] == "1" then 101 if options.server_fallback then 102 mp.add_timeout(0, function() getranges(true, true, "") end) 103 else 104 return mp.osd_message("[sponsorblock] database update failed, gave up") 105 end 106 end 107 if db ~= "" and db ~= database_file then db = database_file end 108 if exists ~= true and not file_exists(db) then 109 if not retrying then 110 mp.osd_message("[sponsorblock] database update failed, retrying...") 111 retrying = true 112 end 113 return update() 114 end 115 if retrying then 116 mp.osd_message("[sponsorblock] database update succeeded") 117 retrying = false 118 end 119 local sponsors 120 local args = { 121 options.python_path, 122 sponsorblock, 123 "ranges", 124 db, 125 options.server_address, 126 youtube_id 127 } 128 if not legacy then 129 sponsors = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) 130 else 131 sponsors = utils.subprocess({args = args}) 132 end 133 if not string.match(sponsors.stdout, "^%s*(.*%S)") then return end 134 if string.match(sponsors.stdout, "error") then return getranges(true, true) end 135 local new_ranges = {} 136 local r_count = 0 137 if more then r_count = -1 end 138 for t in string.gmatch(sponsors.stdout, "[^:%s]+") do 139 uuid = string.match(t, '[^,]+$') 140 if ranges[uuid] then 141 new_ranges[uuid] = ranges[uuid] 142 else 143 start_time = tonumber(string.match(t, '[^,]+')) 144 end_time = tonumber(string.sub(string.match(t, ',[^,]+'), 2)) 145 if end_time - start_time >= options.min_duration then 146 new_ranges[uuid] = { 147 start_time = start_time, 148 end_time = end_time, 149 skipped = false 150 } 151 end 152 end 153 r_count = r_count + 1 154 end 155 local c_count = t_count(ranges) 156 if c_count == 0 or r_count >= c_count then 157 ranges = new_ranges 158 end 159 end 160 161 function fast_forward() 162 local last_speed = mp.get_property_number("speed") 163 local new_speed = math.min(last_speed + options.fast_forward_increase, options.fast_forward_cap) 164 if new_speed <= last_speed then return end 165 mp.set_property("speed", new_speed) 166 end 167 168 function fade_audio(step) 169 local last_volume = mp.get_property_number("volume") 170 local new_volume = math.max(options.audio_fade_cap, math.min(last_volume + step, volume_before)) 171 if new_volume == last_volume then 172 if step >= 0 then fade_dir = nil end 173 if fade_timer ~= nil then fade_timer:kill() end 174 fade_timer = nil 175 return 176 end 177 mp.set_property("volume", new_volume) 178 end 179 180 function skip_ads(name, pos) 181 if pos == nil then return end 182 local sponsor_ahead = false 183 for uuid, t in pairs(ranges) do 184 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 185 if options.fast_forward == uuid then return end 186 if options.fast_forward == false then 187 mp.osd_message("[sponsorblock] sponsor skipped") 188 mp.set_property("time-pos", t.end_time) 189 else 190 mp.osd_message("[sponsorblock] skipping sponsor") 191 end 192 t.skipped = true 193 last_skip = {uuid = uuid, dir = nil} 194 if options.report_views or options.auto_upvote then 195 local args = { 196 options.python_path, 197 sponsorblock, 198 "stats", 199 database_file, 200 options.server_address, 201 youtube_id, 202 uuid, 203 options.report_views and "1" or "", 204 uid_path, 205 options.user_id, 206 options.auto_upvote and "1" or "" 207 } 208 if not legacy then 209 mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) 210 else 211 utils.subprocess_detached({args = args}) 212 end 213 end 214 if options.fast_forward ~= false then 215 options.fast_forward = uuid 216 speed_timer = mp.add_periodic_timer(1, fast_forward) 217 end 218 return 219 elseif (not options.skip_once or not t.skipped) and t.start_time <= pos + 1 and t.end_time > pos + 1 then 220 sponsor_ahead = true 221 end 222 end 223 if options.audio_fade then 224 if sponsor_ahead then 225 if fade_dir ~= false then 226 if fade_dir == nil then volume_before = mp.get_property_number("volume") end 227 if fade_timer ~= nil then fade_timer:kill() end 228 fade_dir = false 229 fade_timer = mp.add_periodic_timer(.1, function() fade_audio(-options.audio_fade_step) end) 230 end 231 elseif fade_dir == false then 232 fade_dir = true 233 if fade_timer ~= nil then fade_timer:kill() end 234 fade_timer = mp.add_periodic_timer(.1, function() fade_audio(options.audio_fade_step) end) 235 end 236 end 237 if options.fast_forward and options.fast_forward ~= true then 238 options.fast_forward = true 239 speed_timer:kill() 240 mp.set_property("speed", 1) 241 end 242 end 243 244 function vote(dir) 245 if last_skip.uuid == "" then return mp.osd_message("[sponsorblock] no sponsors skipped, can't submit vote") end 246 local updown = dir == "1" and "up" or "down" 247 if last_skip.dir == dir then return mp.osd_message("[sponsorblock] " .. updown .. "vote already submitted") end 248 last_skip.dir = dir 249 local args = { 250 options.python_path, 251 sponsorblock, 252 "stats", 253 database_file, 254 options.server_address, 255 youtube_id, 256 last_skip.uuid, 257 "", 258 uid_path, 259 options.user_id, 260 dir 261 } 262 if not legacy then 263 mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) 264 else 265 utils.subprocess({args = args}) 266 end 267 mp.osd_message("[sponsorblock] " .. updown .. "vote submitted") 268 end 269 270 function update() 271 mp.command_native_async({name = "subprocess", playback_only = false, args = { 272 options.python_path, 273 sponsorblock, 274 "update", 275 database_file, 276 options.server_address 277 }}, getranges) 278 end 279 280 function file_loaded() 281 local initialized = init 282 ranges = {} 283 segment = {a = 0, b = 0, progress = 0} 284 last_skip = {uuid = "", dir = nil} 285 local video_path = mp.get_property("path") 286 local youtube_id1 = string.match(video_path, "https?://youtu%.be/([%a%d%-_]+).*") 287 local youtube_id2 = string.match(video_path, "https?://w?w?w?%.?youtube%.com/v/([%a%d%-_]+).*") 288 local youtube_id3 = string.match(video_path, "/watch%?v=([%a%d%-_]+).*") 289 local youtube_id4 = string.match(video_path, "/embed/([%a%d%-_]+).*") 290 local local_pattern = nil 291 if options.local_pattern ~= "" then 292 local_pattern = string.match(video_path, options.local_pattern) 293 end 294 youtube_id = youtube_id1 or youtube_id2 or youtube_id3 or youtube_id4 or local_pattern 295 if not youtube_id then return end 296 init = true 297 if not options.local_database then 298 getranges(true, true) 299 else 300 local exists = file_exists(database_file) 301 if exists and options.server_fallback then 302 getranges(true, true) 303 mp.add_timeout(0, function() getranges(true, true, "", true) end) 304 elseif exists then 305 getranges(true, true) 306 elseif options.server_fallback then 307 mp.add_timeout(0, function() getranges(true, true, "") end) 308 end 309 end 310 if initialized then return end 311 mp.observe_property("time-pos", "native", skip_ads) 312 if options.display_name ~= "" then 313 local args = { 314 options.python_path, 315 sponsorblock, 316 "username", 317 database_file, 318 options.server_address, 319 youtube_id, 320 "", 321 "", 322 uid_path, 323 options.user_id, 324 options.display_name 325 } 326 if not legacy then 327 mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) 328 else 329 utils.subprocess_detached({args = args}) 330 end 331 end 332 if not options.local_database or (not options.auto_update and file_exists(database_file)) then return end 333 update() 334 end 335 336 function set_segment() 337 if not youtube_id then return end 338 local pos = mp.get_property_number("time-pos") 339 if pos == nil then return end 340 if segment.progress > 1 then 341 segment.progress = segment.progress - 2 342 end 343 if segment.progress == 1 then 344 segment.progress = 0 345 segment.b = pos 346 mp.osd_message("[sponsorblock] segment boundary B set, press again for boundary A", 3) 347 else 348 segment.progress = 1 349 segment.a = pos 350 mp.osd_message("[sponsorblock] segment boundary A set, press again for boundary B", 3) 351 end 352 end 353 354 function submit_segment() 355 if not youtube_id then return end 356 local start_time = math.min(segment.a, segment.b) 357 local end_time = math.max(segment.a, segment.b) 358 if end_time - start_time == 0 or end_time == 0 then 359 mp.osd_message("[sponsorblock] empty segment, not submitting") 360 elseif segment.progress <= 1 then 361 mp.osd_message(string.format("[sponsorblock] press Shift+G again to confirm: %.2d:%.2d:%.2d to %.2d:%.2d:%.2d", start_time/(60*60), start_time/60%60, start_time%60, end_time/(60*60), end_time/60%60, end_time%60), 5) 362 segment.progress = segment.progress + 2 363 else 364 mp.osd_message("[sponsorblock] submitting segment...", 30) 365 local submit 366 local args = { 367 options.python_path, 368 sponsorblock, 369 "submit", 370 database_file, 371 options.server_address, 372 youtube_id, 373 tostring(start_time), 374 tostring(end_time), 375 uid_path, 376 options.user_id 377 } 378 if not legacy then 379 submit = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) 380 else 381 submit = utils.subprocess({args = args}) 382 end 383 if string.match(submit.stdout, "success") then 384 segment = {a = 0, b = 0, progress = 0} 385 mp.osd_message("[sponsorblock] segment submitted") 386 elseif string.match(submit.stdout, "error") then 387 mp.osd_message("[sponsorblock] segment submission failed, server may be down. try again", 5) 388 elseif string.match(submit.stdout, "502") then 389 mp.osd_message("[sponsorblock] segment submission failed, server is down. try again", 5) 390 elseif string.match(submit.stdout, "400") then 391 mp.osd_message("[sponsorblock] segment submission failed, impossible inputs", 5) 392 segment = {a = 0, b = 0, progress = 0} 393 elseif string.match(submit.stdout, "429") then 394 mp.osd_message("[sponsorblock] segment submission failed, rate limited. try again", 5) 395 elseif string.match(submit.stdout, "409") then 396 mp.osd_message("[sponsorblock] segment already submitted", 3) 397 segment = {a = 0, b = 0, progress = 0} 398 else 399 mp.osd_message("[sponsorblock] segment submission failed", 5) 400 end 401 end 402 end 403 404 mp.register_event("file-loaded", file_loaded) 405 mp.add_key_binding("g", "sponsorblock_set_segment", set_segment) 406 mp.add_key_binding("G", "sponsorblock_submit_segment", submit_segment) 407 mp.add_key_binding("h", "sponsorblock_upvote", function() return vote("1") end) 408 mp.add_key_binding("H", "sponsorblock_downvote", function() return vote("0") end)