Skip to content

Instantly share code, notes, and snippets.

@bitingsock
Last active April 28, 2024 08:08
Show Gist options
  • Star 29 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save bitingsock/17d90e3deeb35b5f75e55adb19098f58 to your computer and use it in GitHub Desktop.
Save bitingsock/17d90e3deeb35b5f75e55adb19098f58 to your computer and use it in GitHub Desktop.
Precache the next entry in your playlist if it is a network source by downloading it to a temp file ahead of time. Change Line 20 to temp directory. It will delete the directory on exit.
----------------------
-- #example ytdl_preload.conf
-- # make sure lines do not have trailing whitespace
-- # ytdl_opt has no sanity check and should be formatted exactly how it would appear in yt-dlp CLI, they are split into a key/value pair on whitespace
-- # at least on Windows, do not escape '\' in temp, just us a single one for each divider
-- #temp=R:\ytdltest
-- #ytdl_opt1=-r 50k
-- #ytdl_opt2=-N 5
-- #ytdl_opt#=etc
----------------------
local nextIndex
local caught = true
-- local pop = false
local ytdl = "yt-dlp"
local utils = require 'mp.utils'
local options = require 'mp.options'
local opts = {
temp = "R:\\ytdl",
ytdl_opt1 = "",
ytdl_opt2 = "",
ytdl_opt3 = "",
ytdl_opt4 = "",
ytdl_opt5 = "",
ytdl_opt6 = "",
ytdl_opt7 = "",
ytdl_opt8 = "",
ytdl_opt9 = "",
}
options.read_options(opts, "ytdl_preload")
local additionalOpts = {}
for k, v in pairs(opts) do
if k:find("ytdl_opt%d") and v ~= "" then
additionalOpts[k] = v
-- print("entry")
-- print(k .. v)
end
end
local cachePath = opts.temp
local chapter_list = {}
local json = ""
local filesToDelete = {}
local function exists(file)
local ok, err, code = os.rename(file, file)
if not ok then
if code == 13 then -- Permission denied, but it exists
return true
end
end
return ok, err
end
local function useNewLoadfile()
for _, c in pairs(mp.get_property_native("command-list")) do
if c["name"] == "loadfile" then
for _, a in pairs(c["args"]) do
if a["name"] == "index" then
return true
end
end
end
end
end
--from ytdl_hook
local function time_to_secs(time_string)
local ret
local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 3600 + b * 60 + c)
else
a, b = time_string:match("(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 60 + b)
end
end
return ret
end
local function extract_chapters(data, video_length)
local ret = {}
for line in data:gmatch("[^\r\n]+") do
local time = time_to_secs(line)
if time and (time < video_length) then
table.insert(ret, { time = time, title = line })
end
end
table.sort(ret, function(a, b) return a.time < b.time end)
return ret
end
local function chapters()
if json.chapters then
for i = 1, #json.chapters do
local chapter = json.chapters[i]
local title = chapter.title or ""
if title == "" then
title = string.format('Chapter %02d', i)
end
table.insert(chapter_list, { time = chapter.start_time, title = title })
end
elseif not (json.description == nil) and not (json.duration == nil) then
chapter_list = extract_chapters(json.description, json.duration)
end
end
--end ytdl_hook
local title = ""
local fVideo = ""
local fAudio = ""
local function load_files(dtitle, destination, audio, wait)
if wait then
if exists(destination .. ".mka") then
print("---wait success: found mka---")
audio = "audio-file=" .. destination .. '.mka,'
else
print("---could not find mka after wait, audio may be missing---")
end
end
-- if audio ~= "" then
-- table.insert(filesToDelete, destination .. ".mka")
-- end
-- table.insert(filesToDelete, destination .. ".mkv")
dtitle = dtitle:gsub("-" .. ("[%w_-]"):rep(11) .. "$", "")
dtitle = dtitle:gsub("^" .. ("%d"):rep(10) .. "%-", "")
if useNewLoadfile() then
mp.commandv("loadfile", destination .. ".mkv", "append", -1,
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no')
else
mp.commandv("loadfile", destination .. ".mkv", "append",
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no') --,sub-file="..destination..".en.vtt") --in case they are not set up to autoload
end
mp.commandv("playlist_move", mp.get_property("playlist-count") - 1, nextIndex)
mp.commandv("playlist_remove", nextIndex + 1)
caught = true
title = ""
-- pop = true
end
local listenID = ""
local function listener(event)
if not caught and event.prefix == mp.get_script_name() and string.find(event.text, listenID) then
local destination = string.match(event.text, "%[download%] Destination: (.+).mkv") or
string.match(event.text, "%[download%] (.+).mkv has already been downloaded")
-- if destination then print("---"..cachePath) end;
if destination and string.find(destination, string.gsub(cachePath, '~/', '')) then
-- print(listenID)
mp.unregister_event(listener)
_, title = utils.split_path(destination)
local audio = ""
if fAudio == "" then
load_files(title, destination, audio, false)
else
if exists(destination .. ".mka") then
audio = "audio-file=" .. destination .. '.mka,'
load_files(title, destination, audio, false)
else
print("---expected mka but could not find it, waiting for 2 seconds---")
mp.add_timeout(2, function()
load_files(title, destination, audio, true)
end)
end
end
end
end
end
--from ytdl_hook
mp.add_hook("on_preloaded", 10, function()
if string.find(mp.get_property("path"), cachePath) then
chapters()
if next(chapter_list) ~= nil then
mp.set_property_native("chapter-list", chapter_list)
chapter_list = {}
json = ""
end
end
end)
--end ytdl_hook
function dump(o)
if type(o) == 'table' then
local s = '{ '
for k, v in pairs(o) do
if type(k) ~= 'number' then k = '"' .. k .. '"' end
s = s .. '[' .. k .. '] = ' .. dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
local function addOPTS(old)
for k, v in pairs(additionalOpts) do
-- print(k)
if string.find(v, "%s") then
for l, w in string.gmatch(v, "([-%w]+) (.+)") do
table.insert(old, l)
table.insert(old, w)
end
else
table.insert(old, v)
end
end
-- print(dump(old))
return old
end
local AudioDownloadHandle = {}
local VideoDownloadHandle = {}
local JsonDownloadHandle = {}
local function download_files(id, success, result, error)
if result.killed_by_us then
return
end
local jfile = cachePath .. "/" .. id .. ".json"
local jfileIO = io.open(jfile, "w")
jfileIO:write(result.stdout)
jfileIO:close()
json = utils.parse_json(result.stdout)
-- print(dump(json))
if json.requested_downloads[1].requested_formats ~= nil then
local args = { ytdl, "--no-continue", "-q", "-f", fAudio, "--restrict-filenames", "--no-playlist", "--no-part",
"-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mka", "--load-info-json", jfile }
args = addOPTS(args)
AudioDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
else
fAudio = ""
fVideo = fVideo:gsub("bestvideo", "best")
fVideo = fVideo:gsub("bv", "best")
end
local args = { ytdl, "--no-continue", "-f", fVideo .. '/best', "--restrict-filenames", "--no-playlist",
"--no-part", "-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mkv", "--load-info-json", jfile }
args = addOPTS(args)
VideoDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
end
local function DL()
local index = tonumber(mp.get_property("playlist-pos"))
if mp.get_property("playlist/" .. index .. "/filename"):find("/videos$") and mp.get_property("playlist/" .. index + 1 .. "/filename"):find("/shorts$") then
return
end
if tonumber(mp.get_property("playlist-pos-1")) > 0 and mp.get_property("playlist-pos-1") ~= mp.get_property("playlist-count") then
nextIndex = index + 1
local nextFile = mp.get_property("playlist/" .. nextIndex .. "/filename")
if nextFile and caught and nextFile:find("://", 0, false) then
caught = false
mp.enable_messages("info")
mp.register_event("log-message", listener)
local ytFormat = mp.get_property("ytdl-format")
fVideo = string.match(ytFormat, '(.+)%+.+//?') or 'bestvideo'
fAudio = string.match(ytFormat, '.+%+(.+)//?') or 'bestaudio'
-- print("start"..nextFile)
listenID = tostring(os.time())
local args = { ytdl, "--dump-single-json", "--no-simulate", "--skip-download",
"--restrict-filenames",
"--no-playlist", "--sub-lang", "en", "--write-sub", "--no-part", "-o",
cachePath .. "/" .. listenID .. "-%(title)s-%(id)s.%(ext)s", nextFile }
args = addOPTS(args)
-- print(dump(args))
table.insert(filesToDelete, listenID)
JsonDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true,
playback_only = false
}, function(...)
download_files(listenID, ...)
end)
end
end
end
local function clearCache()
-- print(pop)
--if pop == true then
mp.abort_async_command(AudioDownloadHandle)
mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
-- for k, v in pairs(filesToDelete) do
-- print("remove: " .. v)
-- os.remove(v)
-- end
local ftd = io.open(cachePath .. "/temp.files", "a")
for k, v in pairs(filesToDelete) do
ftd:write(v .. "\n")
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. v .. '*"')
else
os.execute('rm -f ' .. cachePath .. "/" .. v .. "*")
end
end
ftd:close()
print('clear')
mp.command("quit")
--end
end
mp.add_hook("on_unload", 50, function()
-- mp.abort_async_command(AudioDownloadHandle)
-- mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
mp.unregister_event(listener)
caught = true
listenID = "resetYtdlPreloadListener"
-- print(listenID)
end)
local skipInitial
mp.observe_property("playlist-count", "number", function()
if skipInitial then
DL()
else
skipInitial = true
end
end)
--from ytdl_hook
local platform_is_windows = (package.config:sub(1, 1) == "\\")
local o = {
exclude = "",
try_ytdl_first = false,
use_manifests = false,
all_formats = false,
force_all_formats = true,
ytdl_path = "",
}
local paths_to_search = { "yt-dlp", "yt-dlp_x86", "youtube-dl" }
--local options = require 'mp.options'
options.read_options(o, "ytdl_hook")
local separator = platform_is_windows and ";" or ":"
if o.ytdl_path:match("[^" .. separator .. "]") then
paths_to_search = {}
for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do
table.insert(paths_to_search, path)
end
end
local function exec(args)
local ret = mp.command_native({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true
})
return ret.status, ret.stdout, ret, ret.killed_by_us
end
local msg = require 'mp.msg'
local command = {}
for _, path in pairs(paths_to_search) do
-- search for youtube-dl in mpv's config dir
local exesuf = platform_is_windows and ".exe" or ""
local ytdl_cmd = mp.find_config_file(path .. exesuf)
if ytdl_cmd then
msg.verbose("Found youtube-dl at: " .. ytdl_cmd)
ytdl = ytdl_cmd
break
else
msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories")
--search in PATH
command[1] = path
es, json, result, aborted = exec(command)
if result.error_string == "init" then
msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions")
else
msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH")
ytdl = path
break
end
end
end
--end ytdl_hook
mp.register_event("start-file", DL)
mp.register_event("shutdown", clearCache)
local ftd = io.open(cachePath .. "/temp.files", "r")
while ftd ~= nil do
local line = ftd:read()
if line == nil or line == "" then
ftd:close()
io.open(cachePath .. "/temp.files", "w"):close()
break
end
-- print("DEL::"..line)
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. line .. '*" >nul 2>nul')
else
os.execute('rm -f ' .. cachePath .. "/" .. line .. "* &> /dev/null")
end
end
@bitingsock
Copy link
Author

ok, it should use the youtube-dl in your path and also respects ytdl-format!

@dorsiflexion
Copy link

Thank you! Now I can see the file being (fully) downloaded. But the file is not actually used, because when starting the next video (which was downloaded) I can see the caching starting from the beginning plus a few seconds waiting time before the video is playing. Also mpv outputs [ytdl_preload] ERROR: Stream #0:0 -> #0:0 (copy) right at the beginning of a video, when preloading also starts.

@bitingsock
Copy link
Author

bitingsock commented Sep 18, 2021

ok try now. fixed more hardcoded stuff, and tested on ubuntu

@dorsiflexion
Copy link

It seems to work now. Thanks a ton!

@bitingsock
Copy link
Author

should work with sponsorblock now too

Copy link

ghost commented Dec 12, 2021

Can you update for yt-dlp which mpv now seeks and uses by default?

Copy link

ghost commented Dec 12, 2021

Is the "no such file or directory" error expected when playing local files?

rm: cannot remove '/tmp/yt-dlp': No such file or directory

When playing remote files the cache directory and files are created as expected.

@bitingsock
Copy link
Author

bitingsock commented Dec 12, 2021

updated

  • use yt-dlp
  • doesn't try to erase if nothing was made

Copy link

ghost commented Dec 13, 2021

Great. Is it possible to search for both youtube-dl and yt-dlp, accounting for the majority which only has the former installed?

mpv also only prioritizes yt-dlp.

@bitingsock
Copy link
Author

bitingsock commented Dec 13, 2021

It will know search but the executable has to be in a config folder (i.e. .\portable_config); mpv's directory does not currently count as a config folder. If it doesn't find it there it will try to use yt-dlp from PATH.

@bitingsock
Copy link
Author

Ok, it should retrieve the location the same way mpv does.

@DuendeInexistente
Copy link

DuendeInexistente commented Feb 5, 2022

Having errors in mpv 0.33 in ubuntu

[ytdl_preload] 
[ytdl_preload] stack traceback:
[ytdl_preload] 	[string "/home/cammera/.config/mpv/scripts/ytdl-preloa..."]:14: in function 'handler'
[ytdl_preload] 	mp.defaults:500: in function 'call_event_handlers'
[ytdl_preload] 	mp.defaults:534: in function 'dispatch_events'
[ytdl_preload] 	mp.defaults:493: in function <mp.defaults:492>
[ytdl_preload] 	[C]: in ?
[ytdl_preload] 	[C]: in ?
[ytdl_preload] Lua error: [string "/home/cammera/.config/mpv/scripts/ytdl-preloa..."]:14: attempt to concatenate upvalue 'title' (a nil value)

It seems to cache the first next video, and then crash after that

@bitingsock
Copy link
Author

@DuendeInexistente
ok should be fixed

@DuendeInexistente
Copy link

Thanks! It's working now

@bitingsock
Copy link
Author

  • added chapter insertion
  • expanded demuxer bytes from 1mb to 3mb
  • removed download delay
  • removed Video ID from media title

@bitingsock
Copy link
Author

bitingsock commented Apr 8, 2023

  • removed debug messages
  • better: removed Video ID from media title
  • improved video format handling

@BhaturaGuy
Copy link

support script-opts?
To change temp directory and download arguments?

@bitingsock
Copy link
Author

@BhaturaGuy could you give an example case where it's useful?
download format is already extracted from your ytdl-format option.

@BhaturaGuy
Copy link

  1. Won't have to change cache path each and every time we update the script.

download format is already extracted from your ytdl-format option.

  1. True but it doesn't extract ytdl-raw-options, which is somewhat important since I use ytdl-raw-options=format-sort=[vbr,abr] cause default bv+ba sucks.
  2. There might be more reasons but I don't see one why not to have script-opts for simply flexibility?

Copy link

ghost commented Sep 16, 2023

Doesn't appear to currently work with Youtube Music. Neither does yt-dlp 2023.07.06, which reports that 'Youtube Music is not directly supported', but mpv 0.36.0 can nonetheless play entire playlists (i.e. albums; with a delay between track changes of course) at the time.

$ mpv --no-video --audio-client-name=mpv-audio 'https://music.youtube.com/playlist?list=*'

example album

@bitingsock
Copy link
Author

bitingsock commented Sep 16, 2023

@07416 I think it is working correctly. You are probably still getting a delay with the script because the lookup for the next video is a blocking call. You should still be downloading the next(next) video after that.

I think fixing the delay should be easy, I'll work on it.

Copy link

ghost commented Sep 16, 2023

@bitingsock I forgot to mention that I'm on Fedora (38) with their limited ffmpeg build (I recently disabled the RPM Fusion repos and moved to mpv and VLC Flatpaks). I could have reproduced in a VM with full logs.

I'll reply if the issue persists with a feature-complete ffmpeg build.

@bitingsock
Copy link
Author

bitingsock commented Sep 16, 2023

@07416 I don't think ffmpeg limitations would matter if mpv can already play them. try ytdl-preload-test when you get a chance. I changed the blocking call to async.

@bitingsock
Copy link
Author

bitingsock commented Oct 24, 2023

@BhaturaGuy
please try this new test version, it now includes use of script_opts with custom download args and custom tempdir, better asynchronous downloads, and more robust logic for switching downloads and deleting files

@lbpth
Copy link

lbpth commented Jan 13, 2024

I created ytdl-preload.lua with this script and put it in scripts folder
When I tried to open Youtube video by using mpv, the MPV window is opened in 1s then close automatically
Which wrong step did I do? How to do it in right way

image

@nhathoang24
Copy link

I created ytdl-preload.lua with this script and put it in scripts folder When I tried to open Youtube video by using mpv, the MPV window is opened in 1s then close automatically Which wrong step did I do? How to do it in right way

image

Same issue with you

@bitingsock
Copy link
Author

Try the new version. I have updated it here. It is the same as my experimental version I mentioned above.

@bitingsock
Copy link
Author

I've updated the script to be compatible with mpv after mpv-player/mpv@c678033

@Deshdeepak1
Copy link

I set ytdl-format in my mpv conf and possible some other ytdl options in future, Can't this pick those options and set it

@bitingsock
Copy link
Author

@Deshdeepak1 It tries to extract the formats from the options on line 260 local ytFormat = mp.get_property("ytdl-format")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment