dotfiles

<-- duh.
git clone https://hhvn.uk/dotfiles
git clone git://hhvn.uk/dotfiles
Log | Files | Refs | Submodules | LICENSE

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)