Files
mpv/submodules/mpv-youtube-upnext/youtube-upnext.lua

1585 lines
51 KiB
Lua

-- youtube-upnext.lua
--
-- Fetch upnext/recommended videos from youtube
-- This is forked/based on https://github.com/jgreco/mpv-youtube-quality
--
-- Diplays a menu that lets you load the upnext/recommended video from youtube
-- that appear on the right side on the youtube website.
-- If auto_add is set to true (default), the 'up next' video is automatically
-- appended to the current playlist
--
-- Bound to ctrl-u by default.
--
-- Requires curl/curl.exe or wget/wget.exe in PATH. On Windows with wget you may need
-- to set check_certificate to false, otherwise wget.exe might not be able to
-- download the youtube website.
local mp = require "mp"
local utils = require "mp.utils"
local msg = require "mp.msg"
local assdraw = require "mp.assdraw"
local opts = {
--key bindings
toggle_menu_binding = "ctrl+u",
up_binding = "UP",
down_binding = "DOWN",
select_binding = "ENTER",
append_binding = "SPACE",
close_binding = "ESC",
--auto fetch recommended videos when opening a url
fetch_on_start = true,
--auto load and add the "upnext" video to the playlist
auto_add = true,
--formatting / cursors
cursor_selected = "",
cursor_unselected = "",
cursor_appended = "",
cursor_appended_selected = "",
--font size scales by window, if false requires larger font and padding sizes
scale_playlist_by_window = false,
--playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua
--example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1
--read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags
--undeclared tags will use default osd settings
--these styles will be used for the whole playlist. More specific styling will need to be hacked in
--
--(a monospaced font is recommended but not required e.g. {\\fnmonospace\\fs25} )
style_ass_tags = "",
--paddings for top left corner
text_padding_x = 5,
text_padding_y = 5,
menu_timeout = 10,
--Screen dim when menu is open 0.0 - 1.0 (0 is no dim, 1 is black)
curtain_opacity = 0.7,
youtube_url = "https://www.youtube.com/watch?v=%s",
-- Fallback Invidious instance, see https://api.invidious.io/ for alternatives
invidious_instance = "https://inv.tux.pizza",
-- Keep the width of the window the same when the next video is played
restore_window_width = false,
-- On Windows wget.exe may not be able to check SSL certificates for HTTPS, so you can disable checking here
check_certificate = true,
-- Use a cookies file
-- (Same as youtube-dl --cookies or curl -b or wget --load-cookies)
-- If you don't set this, the script will try to create a temporary cookies file for you
-- On Windows you need to use a double blackslash or a single fordwardslash
-- For example "C:\\Users\\Username\\cookies.txt"
-- Or "C:/Users/Username/cookies.txt"
-- Alternatively you can set this from the command line with --ytdl-raw-options=cookies=file.txt
cookies = "",
-- When a video is selected from the menu, the new video can be appended to the playlist
-- or the playlist can be cleared and replaced with only the selected video.
-- If true, the video will be appended to the playlist. If false, the playlist will be cleared.
keep_playlist_on_select = true,
-- What should happen if a video recommendation in uosc menu is clicked? Options are:
-- 'submenu' -- show a submenu with play/upnext/append option
-- 'append' -- append the video to the playlist
-- 'insert' -- play the video after the current video
-- 'play' -- append the video to the playlist and play it
-- 'replace' -- play the video and clear the playlist
uosc_entry_action = "submenu",
-- Should the uosc menu stay open after clicking a video recommendation
uosc_keep_menu_open = false,
-- Use json.lua library instead of mpv's built-in json parser
use_json_lua = false,
-- Don't play/append videos that are shorter than this time. Format is "HH:MM:SS" or "MM:SS"
skip_shorter_than = "",
-- Don't play/append videos that are longer than this time. Format is "HH:MM:SS" or "MM:SS"
skip_longer_than = "",
-- Don't show the videos that are too short or too long in the menu
hide_skipped_videos = false,
}
(require "mp.options").read_options(opts, "youtube-upnext")
-- Command line options
if opts.cookies == nil or opts.cookies == "" then
local raw_options = mp.get_property_native("options/ytdl-raw-options")
for param, arg in pairs(raw_options) do
if (param == "cookies") and (arg ~= "") then
opts.cookies = arg
end
end
end
local script_name = mp.get_script_name()
local destroyer = nil
local upnext_cache = {}
local prefered_win_width = nil
local last_dheight = nil
local last_dwidth = nil
local watched_ids = {}
local appended_to_playlist = {}
local json = {}
local function table_size(t)
local s = 0
for _, _ in ipairs(t) do
s = s + 1
end
return s
end
local function exec(args, capture_stdout, capture_stderr)
local ret =
mp.command_native(
{
name = "subprocess",
playback_only = false,
capture_stdout = capture_stdout,
capture_stderr = capture_stderr,
args = args
}
)
return ret.status, ret.stdout, ret.stderr, ret
end
local function url_encode(s)
local function repl(x)
return string.format("%%%02X", string.byte(x))
end
return string.gsub(s, "([^0-9a-zA-Z!'()*._~-])", repl)
end
local function parse_yt_time(hour_min_second_string)
local hour, min, sec = string.match(hour_min_second_string, "(%d+):(%d+):(%d+)")
if hour == nil then
min, sec = string.match(hour_min_second_string, "(%d+):(%d+)")
end
if min == nil then
sec = string.match(hour_min_second_string, "(%d+)")
end
return (hour or 0) * 3600 + (min or 0) * 60 + (sec or 0)
end
local function create_yt_time(seconds)
local hour = math.floor(seconds / 3600)
local min = math.floor((seconds - hour * 3600) / 60)
local sec = math.floor(seconds - hour * 3600 - min * 60)
if hour > 0 then
return string.format("%02d:%02d:%02d", hour, min, sec)
end
return string.format("%02d:%02d", min, sec)
end
local function extract_videoid(url)
local video_id = nil
if string.find(url, "youtu") ~= nil then
-- extract vidoe id from https://www.youtube.com/watch?v=abcd_1234-ef
local s, e = string.find(url, "v=[^#?!&]+")
if s ~= nil then
video_id = string.sub(url, s + 2, e)
else
-- extract from https://youtu.be/abcd_1234-ef
local s2, e2 = string.find(url, "youtu.be/[^#?!&]+")
if s2 ~= nil then
video_id = string.sub(url, s2 + 9, e2)
end
end
end
return video_id
end
local skip_shorter_than = -1
if opts.skip_shorter_than ~= nil and opts.skip_shorter_than ~= "" then
skip_shorter_than = parse_yt_time(opts.skip_shorter_than)
end
local skip_longer_than = -1
if opts.skip_longer_than ~= nil and opts.skip_longer_than ~= "" then
skip_longer_than = parse_yt_time(opts.skip_longer_than)
end
if skip_longer_than > -1 and skip_shorter_than > -1 and skip_longer_than < skip_shorter_than then
msg.error("skip_longer_than must be greater than skip_shorter_than")
skip_longer_than = -1
skip_shorter_than = -1
end
local function download_upnext(url, post_data)
if opts.fetch_on_start or opts.auto_add then
msg.info("fetching 'up next' with curl...")
else
mp.osd_message("fetching 'up next' with curl...", 60)
end
local command = {"curl", "--silent", "--location"}
if post_data then
table.insert(command, "--request")
table.insert(command, "POST")
table.insert(command, "--data")
table.insert(command, post_data)
end
if opts.cookies == nil or opts.cookies == "" then
table.insert(command, "--cookie-jar")
table.insert(command, opts.cookies)
table.insert(command, "--cookie")
table.insert(command, opts.cookies)
end
table.insert(command, url)
local es, s, _, _ = exec(command, true)
if (es ~= 0) or (s == nil) or (s == "") then
if es == -1 or es == -3 or es == 127 or es == 9009 then
-- MP_SUBPROCESS_EINIT is -3 which can mean the command was not found:
-- https://github.com/mpv-player/mpv/blob/24dcb5d167ba9580119e0b9cc26f79b1d155fcdc/osdep/subprocess-posix.c#L335-L336
msg.debug("curl not found, trying wget")
local command_wget = {"wget", "-q", "-O", "-"}
if not opts.check_certificate then
table.insert(command_wget, "--no-check-certificate")
end
if post_data then
table.insert(command_wget, "--post-data")
table.insert(command_wget, post_data)
end
if opts.cookies then
table.insert(command_wget, "--load-cookies")
table.insert(command_wget, opts.cookies)
table.insert(command_wget, "--save-cookies")
table.insert(command_wget, opts.cookies)
table.insert(command_wget, "--keep-session-cookies")
end
table.insert(command_wget, url)
es, s, _, _ = exec(command, true)
if (es ~= 0) or (s == nil) or (s == "") then
mp.osd_message("upnext failed: curl was not found, wget failed", 10)
return "{}"
end
else
mp.osd_message("upnext failed: error=" .. tostring(es), 10)
msg.error("failed to get upnext list: error=" .. tostring(es))
msg.error("s: " .. tostring(s))
msg.debug("exec (async): " .. table.concat(command, " "))
return "{}"
end
end
local consent_pos = s:find('action="https://consent.youtube.com/s"')
if consent_pos ~= nil then
-- Accept cookie consent form
msg.debug("Need to accept cookie consent form")
s = s:sub(s:find(">", consent_pos + 1, true), s:find("</form", consent_pos + 1, true))
local post_str = ""
for k, v in string.gmatch(s, 'name="([^"]+)" value="([^"]*)"') do
msg.debug("name=" .. tostring(k) .. " value=" .. tostring(v))
post_str = post_str .. url_encode(k) .. "=" .. url_encode(v) .. "&"
end
msg.debug("post-data=" .. tostring(post_str))
if opts.cookies == nil or opts.cookies == "" then
local temp_dir = os.getenv("TEMP")
if temp_dir == nil or temp_dir == "" then
temp_dir = os.getenv("XDG_RUNTIME_DIR")
end
if temp_dir == nil or temp_dir == "" then
opts.cookies = os.tmpname()
else
opts.cookies = temp_dir .. "/youtube-upnext.cookies"
end
msg.warn(
'Created a cookies jar file at "' ..
tostring(opts.cookies) .. '". To hide this warning, set a cookies file in the script configuration'
)
end
return download_upnext("https://consent.youtube.com/s", post_str)
end
local pos1 = string.find(s, "ytInitialData =", 1, true)
if pos1 == nil then
mp.osd_message("upnext failed, no upnext data found err01", 10)
msg.error("failed to find json position 01: pos1=nil")
return "{}"
end
local pos2 = string.find(s, ";%s*</script>", pos1 + 1)
if pos2 ~= nil then
s = string.sub(s, pos1 + 15, pos2 - 1)
return s
else
msg.error("failed to find json position 02")
end
mp.osd_message("upnext failed, no upnext data found err03", 10)
msg.error("failed to get upnext data: pos1=" .. tostring(pos1) .. " pos2=" .. tostring(pos2))
return "{}"
end
local function get_invidious(url)
-- convert to invidious API call
url = string.gsub(url, "https://youtube%.com/watch%?v=", opts.invidious_instance .. "/api/v1/videos/")
url = string.gsub(url, "https://www%.youtube%.com/watch%?v=", opts.invidious_instance .. "/api/v1/videos/")
url = string.gsub(url, "https://youtu%.be/", opts.invidious_instance .. "/api/v1/videos/")
msg.debug("Invidious url:" .. url)
local command = {"curl", "--silent", "--location"}
if not opts.check_certificate then
table.insert(command, "--no-check-certificate")
end
table.insert(command, url)
local es, s, _, _ = exec(command, true)
if (es ~= 0) or (s == nil) or (s == "") then
if es == -1 or es == -3 or es == 127 or es == 9009 then
msg.debug("curl not found, trying wget")
local command_wget = {"wget", "-q", "-O", "-"}
if not opts.check_certificate then
table.insert(command_wget, "--no-check-certificate")
end
table.insert(command_wget, url)
es, s, _, _ = exec(command_wget, true)
if (es ~= 0) or (s == nil) or (s == "") then
mp.osd_message("upnext failed: curl was not found, wget failed", 10)
return {}
end
else
mp.osd_message("upnext failed: error=" .. tostring(es), 10)
msg.error("failed to get invidious: error=" .. tostring(es))
return {}
end
end
local data
local err = nil
if opts.use_json_lua then
data = json.decode(s)
if data == nil then
err = 'json.decode() failed'
end
else
data, err = utils.parse_json(s)
end
if data == nil then
mp.osd_message("upnext fetch failed (Invidious): JSON decode failed", 10)
msg.error("parse_json failed (Invidious): " .. err)
return {}
end
if data.recommendedVideos then
local res = {}
msg.verbose("downloaded and decoded json successfully (Invidious)")
for i, v in ipairs(data.recommendedVideos) do
local duration = -1
if v.lengthSeconds ~= nil then
duration = tonumber(v.lengthSeconds)
end
table.insert(
res,
{
index = i,
label = v.title .. " - " .. v.author,
file = string.format(opts.youtube_url, v.videoId),
length = duration
}
)
end
mp.osd_message("upnext fetch from Invidious succeeded", 10)
return res
elseif data.error then
mp.osd_message("upnext fetch failed (Invidious): " .. data.error, 10)
msg.error("Invidious error: " .. data.error)
else
mp.osd_message("upnext: No recommended videos! (Invidious)", 10)
msg.error("No recommended videos! (Invidious)")
end
return {}
end
local function parse_upnext(json_str, current_video_url)
if json_str == "{}" then
return {}, 0
end
local data
local err = nil
if opts.use_json_lua then
data = json.decode(json_str)
if data == nil then
err = 'json.decode() failed'
end
else
data, err = utils.parse_json(json_str)
end
if data == nil then
mp.osd_message("upnext failed: JSON decode failed", 10)
msg.error("parse_json failed: " .. tostring(err))
msg.debug("Corrupted JSON:\n" .. json_str .. "\n")
return {}, 0
end
local skipped_results = {}
local res = {}
msg.verbose("downloaded and decoded json successfully")
local index = 1
local autoplay_id = nil
if
data.playerOverlays and data.playerOverlays.playerOverlayRenderer and
data.playerOverlays.playerOverlayRenderer.autoplay and
data.playerOverlays.playerOverlayRenderer.autoplay.playerOverlayAutoplayRenderer
then
local playerOverlayAutoplayRenderer = data.playerOverlays.playerOverlayRenderer.autoplay.playerOverlayAutoplayRenderer
local title = playerOverlayAutoplayRenderer.videoTitle.simpleText
local video_id = playerOverlayAutoplayRenderer.videoId
local duration = -1
if playerOverlayAutoplayRenderer.thumbnailOverlays and playerOverlayAutoplayRenderer.thumbnailOverlays[1] and
playerOverlayAutoplayRenderer.thumbnailOverlays[1].thumbnailOverlayTimeStatusRenderer and
playerOverlayAutoplayRenderer.thumbnailOverlays[1].thumbnailOverlayTimeStatusRenderer.text
then
duration = parse_yt_time(playerOverlayAutoplayRenderer.thumbnailOverlays[1].thumbnailOverlayTimeStatusRenderer.text.simpleText)
end
if watched_ids[video_id] == nil then -- Skip if the video was already watched
autoplay_id = video_id
table.insert(
res,
{
index = index,
label = title,
file = string.format(opts.youtube_url, video_id),
length = duration
}
)
index = index + 1
else
table.insert(
skipped_results,
{
index = index,
label = title,
file = string.format(opts.youtube_url, video_id),
length = duration
}
)
index = index + 1
end
end
if
data.playerOverlays and data.playerOverlays.playerOverlayRenderer and
data.playerOverlays.playerOverlayRenderer.endScreen and
data.playerOverlays.playerOverlayRenderer.endScreen.watchNextEndScreenRenderer and
data.playerOverlays.playerOverlayRenderer.endScreen.watchNextEndScreenRenderer.results
then
local n = table_size(data.playerOverlays.playerOverlayRenderer.endScreen.watchNextEndScreenRenderer.results)
for i, v in ipairs(data.playerOverlays.playerOverlayRenderer.endScreen.watchNextEndScreenRenderer.results) do
if v.endScreenVideoRenderer and v.endScreenVideoRenderer.title and v.endScreenVideoRenderer.title.simpleText then
local title = v.endScreenVideoRenderer.title.simpleText
local video_id = v.endScreenVideoRenderer.videoId
local duration = -1
if v.endScreenVideoRenderer.lengthText then
duration = parse_yt_time(v.endScreenVideoRenderer.lengthText.simpleText)
end
if video_id ~= autoplay_id and watched_ids[video_id] == nil then
table.insert(
res,
{
index = index + i,
label = title,
file = string.format(opts.youtube_url, video_id),
length = duration
}
)
elseif watched_ids[video_id] ~= nil then
table.insert(
skipped_results,
{
index = index + i,
label = title,
file = string.format(opts.youtube_url, video_id),
length = duration
}
)
end
end
end
index = index + n
end
if
data.contents and data.contents.twoColumnWatchNextResults and
data.contents.twoColumnWatchNextResults.secondaryResults
then
local secondaryResults = data.contents.twoColumnWatchNextResults.secondaryResults
if secondaryResults.secondaryResults then
secondaryResults = secondaryResults.secondaryResults
end
for i, v in ipairs(secondaryResults.results) do
local compactVideoRenderer = nil
local watchnextindex = index
if
v.compactAutoplayRenderer and v.compactAutoplayRenderer and v.compactAutoplayRenderer.contents and
v.compactAutoplayRenderer.contents.compactVideoRenderer
then
compactVideoRenderer = v.compactAutoplayRenderer.contents.compactVideoRenderer
watchnextindex = 0
elseif v.compactVideoRenderer then
compactVideoRenderer = v.compactVideoRenderer
end
if
compactVideoRenderer and compactVideoRenderer.videoId and compactVideoRenderer.title and
compactVideoRenderer.title.simpleText
then
local title = compactVideoRenderer.title.simpleText
local video_id = compactVideoRenderer.videoId
local duration = -1
if compactVideoRenderer.lengthText then
duration = parse_yt_time(compactVideoRenderer.lengthText.simpleText)
end
local video_url = string.format(opts.youtube_url, video_id)
local duplicate = false
for _, entry in ipairs(res) do
if video_url == entry.file then
duplicate = true
end
end
if watched_ids[video_id] ~= nil then
if not duplicate then
table.insert(
skipped_results,
{
index = watchnextindex + i,
label = title,
file = video_url,
length = duration
}
)
end
duplicate = true
end
if not duplicate then
table.insert(
res,
{
index = watchnextindex + i,
label = title,
file = video_url,
length = duration
}
)
end
end
end
end
-- all results where already watched, reset watched videos and use skipped_results
if table_size(res) == 0 and table_size(skipped_results) > 0 then
msg.debug("All upnext videos are already watched. Watched video list will be reset!")
res = skipped_results
watched_ids = {}
end
table.sort(
res,
function(a, b)
return a.index < b.index
end
)
upnext_cache[current_video_url] = res
return res, table_size(res)
end
local function load_upnext()
local url = mp.get_property("path")
if url == nil then
url = ""
end
url = string.gsub(url, "ytdl://", "") -- Strip possible ytdl:// prefix.
url = string.gsub(url, "/shorts/", "/watch?v=") -- Convert shorts to watch?v=.
url = string.gsub(url, "//.*/watch%?v=", "//youtube.com/watch?v=") -- Account for alternative frontends.
url = string.gsub(url, "%?feature=share", "") -- Strip possible ?feature=share suffix.
if string.find(url, "//youtu.be/") == nil and string.find(url, "//youtube.com/") == nil then
-- SVP calls mpv like this:
-- mpv '--player-operation-mode=pseudo-gui'
-- '--input-ipc-server=mpvpipe' '--no-ytdl' '--audio-file=https://rr3---sn-4g5ednsd.googlevideo.com/videopl....'
-- '--force-media-title=Dog Years'
-- '--http-header-fields=Referer: https://www.youtube.com/watch?v=AbCdEf_Gh,User-Agent: Mozilla/5.0 ...'
-- 'https://rr3---sn-4g5ednsd.googlevideo.com/videoplaybac....'
-- We can extract the url from the header field:
local headers = mp.get_property("http-header-fields")
if headers ~= nil then
local i = headers:find("Referer: ")
if i ~= nil then
i = i + #"Referer: "
local j = headers:find(",", i + 15)
if j ~= nil then
url = headers:sub(i, j - 1)
end
end
end
if string.find(url, "//youtu.be/") == nil and string.find(url, "//youtube.com/") == nil then
-- Neither path nor Referer are a youtube link
return {}, 0
else
-- Disable the '--no-ytdl' option from SVP
mp.set_property_bool("no-ytdl", false)
mp.set_property_bool("ytdl", true)
mp.set_property_bool("autoload-files", false)
end
end
-- don't fetch the website if it's already cached
if upnext_cache[url] ~= nil then
local res = upnext_cache[url]
return res, table_size(res)
end
local res, n = parse_upnext(download_upnext(url, nil), url)
-- Fallback to Invidious API
if n == 0 and opts.invidious_instance and opts.invidious_instance ~= "" then
res = get_invidious(url)
n = table_size(res)
end
return res, n
end
local function add_to_playlist(path, title, length, flag)
if length ~= nil or length < 0 then
length = 0
end
local playlist = "memory://#EXTM3U\n#EXTINF:" .. tostring(length) .. "," .. title .. "\n" .. path
mp.commandv("loadlist", playlist, flag)
if flag ~= "replace" then
mp.commandv("script-message", "add_to_queue", path, 1)
end
end
local function on_file_start(_)
local url = mp.get_property("path")
url = string.gsub(url, "ytdl://", "") -- Strip possible ytdl:// prefix.
url = string.gsub(url, "/shorts/", "/watch?v=") -- Convert shorts to watch?v=.
url = string.gsub(url, "//.*/watch%?v=", "//youtube.com/watch?v=") -- Account for alternative frontends.
url = string.gsub(url, "%?feature=share", "") -- Strip possible ?feature=share suffix.
if string.find(url, "youtu") ~= nil then
-- Try to add current video ID to watched list
-- extract from https://www.youtube.com/watch?v=abcd_1234-ef
local video_id = extract_videoid(url)
if video_id ~= nil then
watched_ids[video_id] = true
msg.debug("Adding to watched_ids: " .. tostring(video_id))
end
local upnext, num_upnext = load_upnext()
if num_upnext > 0 then
if skip_shorter_than > -1 or skip_longer_than > -1 then
-- Append first video that is not too long or too short
for _, v in ipairs(upnext) do
if v ~= nil then
if v.length ~= nil and v.length > 0 then
if skip_shorter_than > -1 and v.length < skip_shorter_than then
goto continue
end
if skip_longer_than > -1 and v.length > skip_longer_than then
goto continue
end
-- Append first video
add_to_playlist(v.file, v.label, v.length, "append")
appended_to_playlist[v.file] = true
return
end
end
::continue::
end
msg.warn("No video between ".. opts.skip_shorter_than .. " and " .. opts.skip_longer_than .. " found")
end
-- Append first video
add_to_playlist(upnext[1].file, upnext[1].label, upnext[1].length, "append")
appended_to_playlist[upnext[1].file] = true
end
end
end
local function show_menu()
local upnext, num_upnext = load_upnext()
if num_upnext == 0 then
return
end
mp.osd_message("", 1)
local timeout
local selected = 1
local function choose_prefix(i, already_appended)
if i == selected and already_appended then
return opts.cursor_appended_selected
elseif i == selected then
return opts.cursor_selected
end
if i ~= selected and already_appended then
return opts.cursor_appended
elseif i ~= selected then
return opts.cursor_unselected
end
return "> " --shouldn't get here
end
local function draw_menu()
local ass = assdraw.ass_new()
local w, h = mp.get_osd_size()
if opts.curtain_opacity ~= nil and opts.curtain_opacity ~= 0 and opts.curtain_opacity < 1.0 then
-- From https://github.com/christoph-heinrich/mpv-quality-menu/blob/501794bfbef468ee6a61e54fc8821fe5cd72c4ed/quality-menu.lua#L699-L707
local alpha = 255 - math.ceil(255 * opts.curtain_opacity)
ass.text = string.format("{\\pos(0,0)\\rDefault\\an7\\1c&H000000&\\alpha&H%X&}", alpha)
ass:draw_start()
ass:rect_cw(0, 0, w, h)
ass:draw_stop()
ass:new_event()
end
ass:pos(opts.text_padding_x, opts.text_padding_y)
ass:append(opts.style_ass_tags)
local skipped = 0
local entries = 0
for i, v in ipairs(upnext) do
if v ~= nil then
local duration = ""
if v.length ~= nil and v.length > 0 then
duration = " " .. create_yt_time(v.length)
if opts.hide_skipped_videos then
if skip_shorter_than > -1 and v.length < skip_shorter_than then
skipped = skipped + 1
goto continue
end
if skip_longer_than > -1 and v.length > skip_longer_than then
skipped = skipped + 1
goto continue
end
end
end
ass:append(choose_prefix(i, appended_to_playlist[v.file] ~= nil) .. v.label .. duration .. "\\N")
entries = entries + 1
end
::continue::
end
if entries == 0 and skipped > 0 then
if skip_shorter_than > -1 and skip_longer_than > -1 then
ass:append("No videos between ".. opts.skip_shorter_than .. " and " .. opts.skip_longer_than .. " found\\N")
elseif skip_shorter_than > -1 then
ass:append("No videos shorter than ".. opts.skip_shorter_than .. " found\\N")
else
ass:append("No videos longer than ".. opts.skip_longer_than .. " found\\N")
end
end
if opts.scale_playlist_by_window then
w, h = 0, 0
end
mp.set_osd_ass(w, h, ass.text)
end
local function update_dimensions()
draw_menu()
end
update_dimensions()
mp.observe_property("osd-dimensions", "native", update_dimensions)
local function selected_move(amt)
selected = selected + amt
if selected < 1 then
selected = num_upnext
elseif selected > num_upnext then
selected = 1
end
timeout:kill()
timeout:resume()
draw_menu()
end
local function destroy()
timeout:kill()
mp.set_osd_ass(0, 0, "")
mp.remove_key_binding("move_up")
mp.remove_key_binding("move_down")
mp.remove_key_binding("select")
mp.remove_key_binding("append")
mp.remove_key_binding("escape")
mp.remove_key_binding("quit")
mp.unobserve_property(update_dimensions)
destroyer = nil
end
timeout = mp.add_periodic_timer(opts.menu_timeout, destroy)
destroyer = destroy
mp.add_forced_key_binding(
opts.up_binding,
"move_up",
function()
selected_move(-1)
end,
{repeatable = true}
)
mp.add_forced_key_binding(
opts.down_binding,
"move_down",
function()
selected_move(1)
end,
{repeatable = true}
)
mp.add_forced_key_binding(
opts.select_binding,
"select",
function()
destroy()
if opts.keep_playlist_on_select then
add_to_playlist(upnext[selected].file, upnext[selected].label, upnext[selected].length, "append-play")
local playlist_index_current = tonumber(mp.get_property("playlist-current-pos", "1"))
local playlist_index_newfile = tonumber(mp.get_property("playlist-count", "1")) - 1
mp.commandv("playlist-move", playlist_index_newfile, playlist_index_current + 1)
mp.commandv("playlist-play-index", playlist_index_current + 1)
appended_to_playlist[upnext[selected].file] = true
else
add_to_playlist(upnext[selected].file, upnext[selected].label, upnext[selected].length, "replace")
end
end
)
mp.add_forced_key_binding(
opts.append_binding,
"append",
function()
-- prevent appending the same video twice
if appended_to_playlist[upnext[selected].file] == true then
timeout:kill()
timeout:resume()
return
else
add_to_playlist(upnext[selected].file, upnext[selected].label, upnext[selected].length, "append")
appended_to_playlist[upnext[selected].file] = true
selected_move(1)
end
end,
{repeatable = true}
)
mp.add_forced_key_binding(opts.close_binding, "quit", destroy)
mp.add_forced_key_binding(opts.toggle_menu_binding, "escape", destroy)
draw_menu()
return
end
local function on_window_scale_changed(_, value)
if value == nil then
return
end
local dwidth = mp.get_property("dwidth")
local dheight = mp.get_property("dheight")
if dwidth ~= nil and dheight ~= nil and dwidth == last_dwidth and dheight == last_dheight then
-- If video size stayed the same, then the scaling was probably done by the user to we save it
local current_window_scale = mp.get_property("current-window-scale")
prefered_win_width = dwidth * current_window_scale
end
end
local function on_dwidth_change(_, value)
if value == nil then
return
end
local dwidth = mp.get_property("dwidth")
local dheight = mp.get_property("dheight")
if dwidth == nil or dheight == nil then
return
end
-- Save new video size
last_dwidth = dwidth
last_dheight = dheight
if prefered_win_width == nil then
return
end
-- Scale window to prefered width
local current_window_scale = mp.get_property("current-window-scale")
local window_width = dwidth * current_window_scale
local new_scale = current_window_scale
if prefered_win_width ~= nil and math.abs(prefered_win_width - window_width) > 2 then
new_scale = prefered_win_width / dwidth
end
if new_scale ~= current_window_scale then
mp.set_property("window-scale", new_scale)
end
end
local function menu_command(...)
return {"script-message-to", script_name, ...}
end
local function open_uosc_menu()
-- uosc menu
local menu_data = {
type = "yt_upnext_menu",
title = "Youtube Recommendations",
keep_open = true,
items = {}
}
for i = 1, 16 do
local icon = i % 2 == 0 and "movie" or "spinner"
table.insert(
menu_data["items"],
{
title = "",
icon = icon,
value = menu_command(),
keep_open = true
}
)
end
local menu_json = utils.format_json(menu_data)
mp.commandv("script-message-to", "uosc", "open-menu", menu_json)
menu_data["items"] = {}
local upnext, num_upnext = load_upnext()
local url = mp.get_property("path")
local not_youtube = url == nil or url:find("ytdl://") ~= 1 and url:find("https?://") ~= 1
local play_action
if opts.keep_playlist_on_select then
play_action = "play"
else
play_action = "replace"
end
local skipped = 0
local entries = 0
for _, v in ipairs(upnext) do
if v ~= nil then
local hint = ""
if appended_to_playlist[v.file] == true then
hint = '' .. hint
end
local video_item = {
title = v.label,
icon = "movie",
hint = hint,
keep_open = opts.uosc_keep_menu_open
}
if v.length ~= nil and v.length > 0 then
video_item.hint = hint .. " " .. create_yt_time(v.length)
if opts.hide_skipped_videos then
if skip_shorter_than > -1 and v.length < skip_shorter_than then
skipped = skipped + 1
goto continue
end
if skip_longer_than > -1 and v.length > skip_longer_than then
skipped = skipped + 1
goto continue
end
end
end
if opts.uosc_entry_action == "submenu" then
video_item["items"] = {
{
title = "Play",
value = menu_command(play_action, v.file, v.label, v.length),
keep_open = opts.uosc_keep_menu_open,
icon = 'play_circle',
},
{
title = "Up Next",
value = menu_command("insert", v.file, v.label, v.length),
keep_open = opts.uosc_keep_menu_open,
icon = 'queue',
},
{
title = "Add to playlist",
value = menu_command("append", v.file, v.label, v.length),
keep_open = opts.uosc_keep_menu_open,
icon = 'add_circle'
}
}
else
video_item["value"] = menu_command(opts.uosc_entry_action, v.file, v.label, v.length)
end
entries = entries + 1
table.insert(menu_data["items"], video_item)
end
::continue::
end
if not_youtube and num_upnext == 0 then
table.insert(
menu_data["items"],
1,
{
title = "Current file is not a youtube video",
icon = "warning",
value = menu_command(),
bold = true,
active = 1,
keep_open = false
}
)
elseif num_upnext == 0 then
table.insert(
menu_data["items"],
1,
{
title = "No results",
icon = "warning",
value = menu_command(),
bold = true,
active = 1,
keep_open = false
}
)
elseif entries == 0 and skipped > 0 then
local title = "No videos longer than ".. opts.skip_longer_than .. " found"
if skip_shorter_than > -1 and skip_longer_than > -1 then
title = "No videos between ".. opts.skip_shorter_than .. " and " .. opts.skip_longer_than .. " found"
elseif skip_shorter_than > -1 then
title = "No videos shorter than ".. opts.skip_shorter_than .. " found"
end
table.insert(
menu_data["items"],
1,
{
title = title,
icon = "warning",
value = menu_command(),
bold = true,
active = 1,
keep_open = false
}
)
end
menu_json = utils.format_json(menu_data)
mp.commandv("script-message-to", "uosc", "update-menu", menu_json)
end
-- register script message to show menu
mp.register_script_message(
"toggle-upnext-menu",
function()
if destroyer ~= nil then
destroyer()
else
show_menu()
end
end
)
-- keybind to launch menu
mp.add_key_binding(opts.toggle_menu_binding, "upnext-menu", show_menu)
if opts.auto_add then
mp.register_event("start-file", on_file_start)
elseif opts.fetch_on_start then
mp.register_event("start-file", load_upnext)
end
if opts.restore_window_width then
mp.observe_property("current-window-scale", "number", on_window_scale_changed)
mp.observe_property("dwidth", "number", on_dwidth_change)
end
-- Open the uosc menu:
mp.register_script_message(
"menu",
function()
open_uosc_menu()
end
)
-- Handle the menu commands from usoc:
mp.register_script_message(
"play",
function(url, label, length)
add_to_playlist(url, label, length, "append-play")
local playlist_index_current = tonumber(mp.get_property("playlist-current-pos", "1"))
local playlist_index_newfile = tonumber(mp.get_property("playlist-count", "1")) - 1
mp.commandv("playlist-move", playlist_index_newfile, playlist_index_current + 1)
mp.commandv("playlist-play-index", playlist_index_current + 1)
appended_to_playlist[url] = true
end
)
mp.register_script_message(
"replace",
function(url, label, length)
add_to_playlist(url, label, length, "replace")
end
)
mp.register_script_message(
"insert",
function(url, label, length)
add_to_playlist(url, label, length, "append")
local playlist_index_current = tonumber(mp.get_property("playlist-current-pos", "1"))
local playlist_index_newfile = tonumber(mp.get_property("playlist-count", "1")) - 1
mp.commandv("playlist-move", playlist_index_newfile, playlist_index_current + 1)
appended_to_playlist[url] = true
end
)
mp.register_script_message(
"append",
function(url, label, length)
add_to_playlist(url, label, length, "append")
appended_to_playlist[url] = true
end
)
local function json_lua()
-- ########################################################################
-- ################### https://github.com/rxi/json.lua ####################
-- ########################################################################
--
-- json.lua
--
-- Copyright (c) 2020 rxi
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy of
-- this software and associated documentation files (the "Software"), to deal in
-- the Software without restriction, including without limitation the rights to
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-- of the Software, and to permit persons to whom the Software is furnished to do
-- so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in all
-- copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
--
local json = { _version = "0.1.2" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\",
[ "\"" ] = "\"",
[ "\b" ] = "b",
[ "\f" ] = "f",
[ "\n" ] = "n",
[ "\r" ] = "r",
[ "\t" ] = "t",
}
local escape_char_map_inv = { [ "/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if rawget(val, 1) ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
local line_count = 1
local col_count = 1
for i = 1, idx - 1 do
col_count = col_count + 1
if str:sub(i, i) == "\n" then
line_count = line_count + 1
col_count = 1
end
end
error( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(1, 4), 16 )
local n2 = tonumber( s:sub(7, 10), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local res = ""
local j = i + 1
local k = j
while j <= #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
elseif x == 92 then -- `\`: Escape
res = res .. str:sub(k, j - 1)
j = j + 1
local c = str:sub(j, j)
if c == "u" then
local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
or str:match("^%x%x%x%x", j + 1)
or decode_error(str, j - 1, "invalid unicode escape in string")
res = res .. parse_unicode_escape(hex)
j = j + #hex
else
if not escape_chars[c] then
decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
end
res = res .. escape_char_map_inv[c]
end
k = j + 1
elseif x == 34 then -- `"`: End of string
res = res .. str:sub(k, j - 1)
return res, j + 1
end
j = j + 1
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
local res, idx = parse(str, next_char(str, 1, space_chars, true))
idx = next_char(str, idx, space_chars, true)
if idx <= #str then
decode_error(str, idx, "trailing garbage")
end
return res
end
-- ########################################################################
-- ######################### End of json.lua ##############################
return json
end
json = json_lua()