config/mpv/scripts/yt-quality.lua

   1 -- youtube-quality.lua
   2 --
   3 -- Change youtube video quality on the fly.
   4 --
   5 -- Diplays a menu that lets you switch to different ytdl-format settings while
   6 -- you're in the middle of a video (just like you were using the web player).
   7 --
   8 -- Bound to ctrl-f by default.
   9 
  10 local mp = require 'mp'
  11 local utils = require 'mp.utils'
  12 local msg = require 'mp.msg'
  13 local assdraw = require 'mp.assdraw'
  14 
  15 local opts = {
  16     --key bindings
  17     toggle_menu_binding = "ctrl+f",
  18     up_binding = "UP",
  19     down_binding = "DOWN",
  20     select_binding = "ENTER",
  21 
  22     --formatting / cursors
  23     selected_and_active     = "▶ - ",
  24     selected_and_inactive   = "● - ",
  25     unselected_and_active   = "▷ - ",
  26     unselected_and_inactive = "○ - ",
  27 
  28     --font size scales by window, if false requires larger font and padding sizes
  29     scale_playlist_by_window=false,
  30 
  31     --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua
  32     --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1
  33     --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags
  34     --undeclared tags will use default osd settings
  35     --these styles will be used for the whole playlist. More specific styling will need to be hacked in
  36     --
  37     --(a monospaced font is recommended but not required)
  38     style_ass_tags = "{\\fnmonospace}",
  39 
  40     --paddings for top left corner
  41     text_padding_x = 5,
  42     text_padding_y = 5,
  43 
  44     --other
  45     menu_timeout = 10,
  46 
  47     --use youtube-dl to fetch a list of available formats (overrides quality_strings)
  48     fetch_formats = false,
  49 
  50     --default menu entries
  51     quality_strings=[[
  52     [
  53       {"1080p": "bestvideo[height<=?1080][fps<=?30]+bestaudio/best"},
  54         {"720p": "bestvideo[height<=?720][fps<=?30]+bestaudio/best"},
  55         {"480p": "bestvideo[height<=?480][fps<=?30]+bestaudio/best"},
  56         {"360p": "bestvideo[height<=?360][fps<=?30]+bestaudio/best"}
  57     ]
  58     ]],
  59 }
  60 (require 'mp.options').read_options(opts, "youtube-quality")
  61 opts.quality_strings = utils.parse_json(opts.quality_strings)
  62 
  63 local destroyer = nil
  64 
  65 
  66 function show_menu()
  67     local selected = 1
  68     local active = 0
  69     local current_ytdl_format = mp.get_property("ytdl-format")
  70     msg.verbose("current ytdl-format: "..current_ytdl_format)
  71     local num_options = 0
  72     local options = {}
  73 
  74 
  75     if opts.fetch_formats then
  76         options, num_options = download_formats()
  77     end
  78 
  79     if next(options) == nil then
  80         for i,v in ipairs(opts.quality_strings) do
  81             num_options = num_options + 1
  82             for k,v2 in pairs(v) do
  83                 options[i] = {label = k, format=v2}
  84                 if v2 == current_ytdl_format then
  85                     active = i
  86                     selected = active
  87                 end
  88             end
  89         end
  90     end
  91 
  92     --set the cursor to the currently format
  93     for i,v in ipairs(options) do
  94         if v.format == current_ytdl_format then
  95             active = i
  96             selected = active
  97             break
  98         end
  99     end
 100 
 101     function selected_move(amt)
 102         selected = selected + amt
 103         if selected < 1 then selected = num_options
 104         elseif selected > num_options then selected = 1 end
 105         timeout:kill()
 106         timeout:resume()
 107         draw_menu()
 108     end
 109     function choose_prefix(i)
 110         if     i == selected and i == active then return opts.selected_and_active
 111         elseif i == selected then return opts.selected_and_inactive end
 112 
 113         if     i ~= selected and i == active then return opts.unselected_and_active
 114         elseif i ~= selected then return opts.unselected_and_inactive end
 115         return "> " --shouldn't get here.
 116     end
 117 
 118     function draw_menu()
 119         local ass = assdraw.ass_new()
 120 
 121         ass:pos(opts.text_padding_x, opts.text_padding_y)
 122         ass:append(opts.style_ass_tags)
 123 
 124         for i,v in ipairs(options) do
 125             ass:append(choose_prefix(i)..v.label.."\\N")
 126         end
 127 
 128         local w, h = mp.get_osd_size()
 129         if opts.scale_playlist_by_window then w,h = 0, 0 end
 130         mp.set_osd_ass(w, h, ass.text)
 131     end
 132 
 133     function destroy()
 134         timeout:kill()
 135         mp.set_osd_ass(0,0,"")
 136         mp.remove_key_binding("move_up")
 137         mp.remove_key_binding("move_down")
 138         mp.remove_key_binding("select")
 139         mp.remove_key_binding("escape")
 140         destroyer = nil
 141     end
 142     timeout = mp.add_periodic_timer(opts.menu_timeout, destroy)
 143     destroyer = destroy
 144 
 145     mp.add_forced_key_binding(opts.up_binding,     "move_up",   function() selected_move(-1) end, {repeatable=true})
 146     mp.add_forced_key_binding(opts.down_binding,   "move_down", function() selected_move(1)  end, {repeatable=true})
 147     mp.add_forced_key_binding(opts.select_binding, "select",    function()
 148         destroy()
 149         mp.set_property("ytdl-format", options[selected].format)
 150         reload_resume()
 151     end)
 152     mp.add_forced_key_binding(opts.toggle_menu_binding, "escape", destroy)
 153 
 154     draw_menu()
 155     return
 156 end
 157 
 158 local ytdl = {
 159     path = "youtube-dl",
 160     searched = false,
 161     blacklisted = {}
 162 }
 163 
 164 format_cache={}
 165 function download_formats()
 166     local function exec(args)
 167         local ret = utils.subprocess({args = args})
 168         return ret.status, ret.stdout, ret
 169     end
 170 
 171     local function table_size(t)
 172         s = 0
 173         for i,v in ipairs(t) do
 174             s = s+1
 175         end
 176         return s
 177     end
 178 
 179     local url = mp.get_property("path")
 180 
 181     url = string.gsub(url, "ytdl://", "") -- Strip possible ytdl:// prefix.
 182 
 183     -- don't fetch the format list if we already have it
 184     if format_cache[url] ~= nil then
 185         local res = format_cache[url]
 186         return res, table_size(res)
 187     end
 188     mp.osd_message("fetching available formats with youtube-dl...", 60)
 189 
 190     if not (ytdl.searched) then
 191         local ytdl_mcd = mp.find_config_file("youtube-dl")
 192         if not (ytdl_mcd == nil) then
 193             msg.verbose("found youtube-dl at: " .. ytdl_mcd)
 194             ytdl.path = ytdl_mcd
 195         end
 196         ytdl.searched = true
 197     end
 198 
 199     local command = {ytdl.path, "--no-warnings", "--no-playlist", "-J"}
 200     table.insert(command, url)
 201     local es, json, result = exec(command)
 202 
 203     if (es < 0) or (json == nil) or (json == "") then
 204         mp.osd_message("fetching formats failed...", 1)
 205         msg.error("failed to get format list: " .. err)
 206         return {}, 0
 207     end
 208 
 209     local json, err = utils.parse_json(json)
 210 
 211     if (json == nil) then
 212         mp.osd_message("fetching formats failed...", 1)
 213         msg.error("failed to parse JSON data: " .. err)
 214         return {}, 0
 215     end
 216 
 217     res = {}
 218     msg.verbose("youtube-dl succeeded!")
 219     for i,v in ipairs(json.formats) do
 220         if v.vcodec ~= "none" then
 221             local fps = v.fps and v.fps.."fps" or ""
 222             local resolution = string.format("%sx%s", v.width, v.height)
 223             local l = string.format("%-9s %-5s (%-4s / %s)", resolution, fps, v.ext, v.vcodec)
 224             local f = string.format("%s+bestaudio/best", v.format_id)
 225             table.insert(res, {label=l, format=f, width=v.width })
 226         end
 227     end
 228 
 229     table.sort(res, function(a, b) return a.width > b.width end)
 230 
 231     mp.osd_message("", 0)
 232     format_cache[url] = res
 233     return res, table_size(res)
 234 end
 235 
 236 
 237 -- register script message to show menu
 238 mp.register_script_message("toggle-quality-menu",
 239 function()
 240     if destroyer ~= nil then
 241         destroyer()
 242     else
 243         show_menu()
 244     end
 245 end)
 246 
 247 -- keybind to launch menu
 248 mp.add_key_binding(opts.toggle_menu_binding, "quality-menu", show_menu)
 249 
 250 -- special thanks to reload.lua (https://github.com/4e6/mpv-reload/)
 251 function reload_resume()
 252     local playlist_pos = mp.get_property_number("playlist-pos")
 253     local reload_duration = mp.get_property_native("duration")
 254     local time_pos = mp.get_property("time-pos")
 255 
 256     mp.set_property_number("playlist-pos", playlist_pos)
 257 
 258     -- Tries to determine live stream vs. pre-recordered VOD. VOD has non-zero
 259     -- duration property. When reloading VOD, to keep the current time position
 260     -- we should provide offset from the start. Stream doesn't have fixed start.
 261     -- Decent choice would be to reload stream from it's current 'live' positon.
 262     -- That's the reason we don't pass the offset when reloading streams.
 263     if reload_duration and reload_duration > 0 then
 264         local function seeker()
 265             mp.commandv("seek", time_pos, "absolute")
 266             mp.unregister_event(seeker)
 267         end
 268         mp.register_event("file-loaded", seeker)
 269     end
 270 end