youtube-quality.lua (8766B)
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 = true, 49 50 --default menu entries 51 quality_strings=[[ 52 [ 53 {"4320p" : "bestvideo[height<=?4320p]+bestaudio/best"}, 54 {"2160p" : "bestvideo[height<=?2160]+bestaudio/best"}, 55 {"1440p" : "bestvideo[height<=?1440]+bestaudio/best"}, 56 {"1080p" : "bestvideo[height<=?1080]+bestaudio/best"}, 57 {"720p" : "bestvideo[height<=?720]+bestaudio/best"}, 58 {"480p" : "bestvideo[height<=?480]+bestaudio/best"}, 59 {"360p" : "bestvideo[height<=?360]+bestaudio/best"}, 60 {"240p" : "bestvideo[height<=?240]+bestaudio/best"}, 61 {"144p" : "bestvideo[height<=?144]+bestaudio/best"} 62 ] 63 ]], 64 } 65 (require 'mp.options').read_options(opts, "youtube-quality") 66 opts.quality_strings = utils.parse_json(opts.quality_strings) 67 68 local destroyer = nil 69 70 71 function show_menu() 72 local selected = 1 73 local active = 0 74 local current_ytdl_format = mp.get_property("ytdl-format") 75 msg.verbose("current ytdl-format: "..current_ytdl_format) 76 local num_options = 0 77 local options = {} 78 79 80 if opts.fetch_formats then 81 options, num_options = download_formats() 82 end 83 84 if next(options) == nil then 85 for i,v in ipairs(opts.quality_strings) do 86 num_options = num_options + 1 87 for k,v2 in pairs(v) do 88 options[i] = {label = k, format=v2} 89 if v2 == current_ytdl_format then 90 active = i 91 selected = active 92 end 93 end 94 end 95 end 96 97 --set the cursor to the currently format 98 for i,v in ipairs(options) do 99 if v.format == current_ytdl_format then 100 active = i 101 selected = active 102 break 103 end 104 end 105 106 function selected_move(amt) 107 selected = selected + amt 108 if selected < 1 then selected = num_options 109 elseif selected > num_options then selected = 1 end 110 timeout:kill() 111 timeout:resume() 112 draw_menu() 113 end 114 function choose_prefix(i) 115 if i == selected and i == active then return opts.selected_and_active 116 elseif i == selected then return opts.selected_and_inactive end 117 118 if i ~= selected and i == active then return opts.unselected_and_active 119 elseif i ~= selected then return opts.unselected_and_inactive end 120 return "> " --shouldn't get here. 121 end 122 123 function draw_menu() 124 local ass = assdraw.ass_new() 125 126 ass:pos(opts.text_padding_x, opts.text_padding_y) 127 ass:append(opts.style_ass_tags) 128 129 for i,v in ipairs(options) do 130 ass:append(choose_prefix(i)..v.label.."\\N") 131 end 132 133 local w, h = mp.get_osd_size() 134 if opts.scale_playlist_by_window then w,h = 0, 0 end 135 mp.set_osd_ass(w, h, ass.text) 136 end 137 138 function destroy() 139 timeout:kill() 140 mp.set_osd_ass(0,0,"") 141 mp.remove_key_binding("move_up") 142 mp.remove_key_binding("move_down") 143 mp.remove_key_binding("select") 144 mp.remove_key_binding("escape") 145 destroyer = nil 146 end 147 timeout = mp.add_periodic_timer(opts.menu_timeout, destroy) 148 destroyer = destroy 149 150 mp.add_forced_key_binding(opts.up_binding, "move_up", function() selected_move(-1) end, {repeatable=true}) 151 mp.add_forced_key_binding(opts.down_binding, "move_down", function() selected_move(1) end, {repeatable=true}) 152 mp.add_forced_key_binding(opts.select_binding, "select", function() 153 destroy() 154 mp.set_property("ytdl-format", options[selected].format) 155 reload_resume() 156 end) 157 mp.add_forced_key_binding(opts.toggle_menu_binding, "escape", destroy) 158 159 draw_menu() 160 return 161 end 162 163 local ytdl = { 164 path = "youtube-dl", 165 searched = false, 166 blacklisted = {} 167 } 168 169 format_cache={} 170 function download_formats() 171 local function exec(args) 172 local ret = utils.subprocess({args = args}) 173 return ret.status, ret.stdout, ret 174 end 175 176 local function table_size(t) 177 s = 0 178 for i,v in ipairs(t) do 179 s = s+1 180 end 181 return s 182 end 183 184 local url = mp.get_property("path") 185 186 url = string.gsub(url, "ytdl://", "") -- Strip possible ytdl:// prefix. 187 188 -- don't fetch the format list if we already have it 189 if format_cache[url] ~= nil then 190 local res = format_cache[url] 191 return res, table_size(res) 192 end 193 mp.osd_message("fetching available formats with youtube-dl...", 60) 194 195 if not (ytdl.searched) then 196 local ytdl_mcd = mp.find_config_file("youtube-dl") 197 if not (ytdl_mcd == nil) then 198 msg.verbose("found youtube-dl at: " .. ytdl_mcd) 199 ytdl.path = ytdl_mcd 200 end 201 ytdl.searched = true 202 end 203 204 local command = {ytdl.path, "--no-warnings", "--no-playlist", "-J"} 205 table.insert(command, url) 206 local es, json, result = exec(command) 207 208 if (es < 0) or (json == nil) or (json == "") then 209 mp.osd_message("fetching formats failed...", 1) 210 msg.error("failed to get format list: " .. err) 211 return {}, 0 212 end 213 214 local json, err = utils.parse_json(json) 215 216 if (json == nil) then 217 mp.osd_message("fetching formats failed...", 1) 218 msg.error("failed to parse JSON data: " .. err) 219 return {}, 0 220 end 221 222 res = {} 223 msg.verbose("youtube-dl succeeded!") 224 for i,v in ipairs(json.formats) do 225 if v.vcodec ~= "none" then 226 local fps = v.fps and v.fps.."fps" or "" 227 local resolution = string.format("%sx%s", v.width, v.height) 228 local l = string.format("%-9s %-5s (%-4s / %s)", resolution, fps, v.ext, v.vcodec) 229 local f = string.format("%s+bestaudio/best", v.format_id) 230 table.insert(res, {label=l, format=f, width=v.width }) 231 end 232 end 233 234 table.sort(res, function(a, b) return a.width > b.width end) 235 236 mp.osd_message("", 0) 237 format_cache[url] = res 238 return res, table_size(res) 239 end 240 241 242 -- register script message to show menu 243 mp.register_script_message("toggle-quality-menu", 244 function() 245 if destroyer ~= nil then 246 destroyer() 247 else 248 show_menu() 249 end 250 end) 251 252 -- keybind to launch menu 253 mp.add_key_binding(opts.toggle_menu_binding, "quality-menu", show_menu) 254 255 -- special thanks to reload.lua (https://github.com/4e6/mpv-reload/) 256 function reload_resume() 257 local playlist_pos = mp.get_property_number("playlist-pos") 258 local reload_duration = mp.get_property_native("duration") 259 local time_pos = mp.get_property("time-pos") 260 261 mp.set_property_number("playlist-pos", playlist_pos) 262 263 -- Tries to determine live stream vs. pre-recordered VOD. VOD has non-zero 264 -- duration property. When reloading VOD, to keep the current time position 265 -- we should provide offset from the start. Stream doesn't have fixed start. 266 -- Decent choice would be to reload stream from it's current 'live' positon. 267 -- That's the reason we don't pass the offset when reloading streams. 268 if reload_duration and reload_duration > 0 then 269 local function seeker() 270 mp.commandv("seek", time_pos, "absolute") 271 mp.unregister_event(seeker) 272 end 273 mp.register_event("file-loaded", seeker) 274 end 275 end