ytdl_hook.lua 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057
  1. local utils = require 'mp.utils'
  2. local msg = require 'mp.msg'
  3. local options = require 'mp.options'
  4. local o = {
  5. exclude = "",
  6. try_ytdl_first = false,
  7. use_manifests = false,
  8. all_formats = false,
  9. force_all_formats = true,
  10. ytdl_path = "",
  11. }
  12. local ytdl = {
  13. path = "",
  14. paths_to_search = {"yt-dlp", "yt-dlp_x86", "youtube-dl"},
  15. searched = false,
  16. blacklisted = {}
  17. }
  18. options.read_options(o, nil, function()
  19. ytdl.blacklisted = {} -- reparse o.exclude next time
  20. ytdl.searched = false
  21. end)
  22. local chapter_list = {}
  23. function Set (t)
  24. local set = {}
  25. for _, v in pairs(t) do set[v] = true end
  26. return set
  27. end
  28. -- ?: surrogate (keep in mind that there is no lazy evaluation)
  29. function iif(cond, if_true, if_false)
  30. if cond then
  31. return if_true
  32. end
  33. return if_false
  34. end
  35. -- youtube-dl JSON name to mpv tag name
  36. local tag_list = {
  37. ["uploader"] = "uploader",
  38. ["channel_url"] = "channel_url",
  39. -- these titles tend to be a bit too long, so hide them on the terminal
  40. -- (default --display-tags does not include this name)
  41. ["description"] = "ytdl_description",
  42. -- "title" is handled by force-media-title
  43. -- tags don't work with all_formats=yes
  44. }
  45. local safe_protos = Set {
  46. "http", "https", "ftp", "ftps",
  47. "rtmp", "rtmps", "rtmpe", "rtmpt", "rtmpts", "rtmpte",
  48. "data"
  49. }
  50. -- For some sites, youtube-dl returns the audio codec (?) only in the "ext" field.
  51. local ext_map = {
  52. ["mp3"] = "mp3",
  53. ["opus"] = "opus",
  54. }
  55. local codec_map = {
  56. -- src pattern = mpv codec
  57. ["vtt"] = "webvtt",
  58. ["opus"] = "opus",
  59. ["vp9"] = "vp9",
  60. ["avc1%..*"] = "h264",
  61. ["av01%..*"] = "av1",
  62. ["mp4a%..*"] = "aac",
  63. }
  64. -- Codec name as reported by youtube-dl mapped to mpv internal codec names.
  65. -- Fun fact: mpv will not really use the codec, but will still try to initialize
  66. -- the codec on track selection (just to scrap it), meaning it's only a hint,
  67. -- but one that may make initialization fail. On the other hand, if the codec
  68. -- is valid but completely different from the actual media, nothing bad happens.
  69. local function map_codec_to_mpv(codec)
  70. if codec == nil then
  71. return nil
  72. end
  73. for k, v in pairs(codec_map) do
  74. local s, e = codec:find(k)
  75. if s == 1 and e == #codec then
  76. return v
  77. end
  78. end
  79. return nil
  80. end
  81. local function platform_is_windows()
  82. return mp.get_property_native("platform") == "windows"
  83. end
  84. local function exec(args)
  85. msg.debug("Running: " .. table.concat(args, " "))
  86. return mp.command_native({
  87. name = "subprocess",
  88. args = args,
  89. capture_stdout = true,
  90. capture_stderr = true,
  91. })
  92. end
  93. -- return true if it was explicitly set on the command line
  94. local function option_was_set(name)
  95. return mp.get_property_bool("option-info/" ..name.. "/set-from-commandline",
  96. false)
  97. end
  98. -- return true if the option was set locally
  99. local function option_was_set_locally(name)
  100. return mp.get_property_bool("option-info/" ..name.. "/set-locally", false)
  101. end
  102. -- youtube-dl may set special http headers for some sites (user-agent, cookies)
  103. local function set_http_headers(http_headers)
  104. if not http_headers then
  105. return
  106. end
  107. local headers = {}
  108. local useragent = http_headers["User-Agent"]
  109. if useragent and not option_was_set("user-agent") then
  110. mp.set_property("file-local-options/user-agent", useragent)
  111. end
  112. local additional_fields = {"Cookie", "Referer", "X-Forwarded-For"}
  113. for idx, item in pairs(additional_fields) do
  114. local field_value = http_headers[item]
  115. if field_value then
  116. headers[#headers + 1] = item .. ": " .. field_value
  117. end
  118. end
  119. if #headers > 0 and not option_was_set("http-header-fields") then
  120. mp.set_property_native("file-local-options/http-header-fields", headers)
  121. end
  122. end
  123. local function append_libav_opt(props, name, value)
  124. if not props then
  125. props = {}
  126. end
  127. if name and value and not props[name] then
  128. props[name] = value
  129. end
  130. return props
  131. end
  132. local function edl_escape(url)
  133. return "%" .. string.len(url) .. "%" .. url
  134. end
  135. local function url_is_safe(url)
  136. local proto = type(url) == "string" and url:match("^(%a[%w+.-]*):") or nil
  137. local safe = proto and safe_protos[proto]
  138. if not safe then
  139. msg.error(("Ignoring potentially unsafe url: '%s'"):format(url))
  140. end
  141. return safe
  142. end
  143. local function time_to_secs(time_string)
  144. local ret
  145. local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)")
  146. if a ~= nil then
  147. ret = (a*3600 + b*60 + c)
  148. else
  149. a, b = time_string:match("(%d%d?):(%d%d)")
  150. if a ~= nil then
  151. ret = (a*60 + b)
  152. end
  153. end
  154. return ret
  155. end
  156. local function extract_chapters(data, video_length)
  157. local ret = {}
  158. for line in data:gmatch("[^\r\n]+") do
  159. local time = time_to_secs(line)
  160. if time and (time < video_length) then
  161. table.insert(ret, {time = time, title = line})
  162. end
  163. end
  164. table.sort(ret, function(a, b) return a.time < b.time end)
  165. return ret
  166. end
  167. local function is_blacklisted(url)
  168. if o.exclude == "" then return false end
  169. if #ytdl.blacklisted == 0 then
  170. for match in o.exclude:gmatch('%|?([^|]+)') do
  171. ytdl.blacklisted[#ytdl.blacklisted + 1] = match
  172. end
  173. end
  174. if #ytdl.blacklisted > 0 then
  175. url = url:match('https?://(.+)')
  176. for _, exclude in ipairs(ytdl.blacklisted) do
  177. if url:match(exclude) then
  178. msg.verbose('URL matches excluded substring. Skipping.')
  179. return true
  180. end
  181. end
  182. end
  183. return false
  184. end
  185. local function parse_yt_playlist(url, json)
  186. -- return 0-based index to use with --playlist-start
  187. if not json.extractor or
  188. (json.extractor ~= "youtube:tab" and
  189. json.extractor ~= "youtube:playlist") then
  190. return nil
  191. end
  192. local query = url:match("%?.+")
  193. if not query then return nil end
  194. local args = {}
  195. for arg, param in query:gmatch("(%a+)=([^&?]+)") do
  196. if arg and param then
  197. args[arg] = param
  198. end
  199. end
  200. local maybe_idx = tonumber(args["index"])
  201. -- if index matches v param it's probably the requested item
  202. if maybe_idx and #json.entries >= maybe_idx and
  203. json.entries[maybe_idx].id == args["v"] then
  204. msg.debug("index matches requested video")
  205. return maybe_idx - 1
  206. end
  207. -- if there's no index or it doesn't match, look for video
  208. for i = 1, #json.entries do
  209. if json.entries[i].id == args["v"] then
  210. msg.debug("found requested video in index " .. (i - 1))
  211. return i - 1
  212. end
  213. end
  214. msg.debug("requested video not found in playlist")
  215. -- if item isn't on the playlist, give up
  216. return nil
  217. end
  218. local function make_absolute_url(base_url, url)
  219. if url:find("https?://") == 1 then return url end
  220. local proto, domain, rest =
  221. base_url:match("(https?://)([^/]+/)(.*)/?")
  222. local segs = {}
  223. rest:gsub("([^/]+)", function(c) table.insert(segs, c) end)
  224. url:gsub("([^/]+)", function(c) table.insert(segs, c) end)
  225. local resolved_url = {}
  226. for i, v in ipairs(segs) do
  227. if v == ".." then
  228. table.remove(resolved_url)
  229. elseif v ~= "." then
  230. table.insert(resolved_url, v)
  231. end
  232. end
  233. return proto .. domain ..
  234. table.concat(resolved_url, "/")
  235. end
  236. local function join_url(base_url, fragment)
  237. local res = ""
  238. if base_url and fragment.path then
  239. res = make_absolute_url(base_url, fragment.path)
  240. elseif fragment.url then
  241. res = fragment.url
  242. end
  243. return res
  244. end
  245. local function edl_track_joined(fragments, protocol, is_live, base)
  246. if not (type(fragments) == "table") or not fragments[1] then
  247. msg.debug("No fragments to join into EDL")
  248. return nil
  249. end
  250. local edl = "edl://"
  251. local offset = 1
  252. local parts = {}
  253. if (protocol == "http_dash_segments") and not is_live then
  254. msg.debug("Using dash")
  255. local args = ""
  256. -- assume MP4 DASH initialization segment
  257. if not fragments[1].duration and #fragments > 1 then
  258. msg.debug("Using init segment")
  259. args = args .. ",init=" .. edl_escape(join_url(base, fragments[1]))
  260. offset = 2
  261. end
  262. table.insert(parts, "!mp4_dash" .. args)
  263. -- Check remaining fragments for duration;
  264. -- if not available in all, give up.
  265. for i = offset, #fragments do
  266. if not fragments[i].duration then
  267. msg.verbose("EDL doesn't support fragments " ..
  268. "without duration with MP4 DASH")
  269. return nil
  270. end
  271. end
  272. end
  273. for i = offset, #fragments do
  274. local fragment = fragments[i]
  275. if not url_is_safe(join_url(base, fragment)) then
  276. return nil
  277. end
  278. table.insert(parts, edl_escape(join_url(base, fragment)))
  279. if fragment.duration then
  280. parts[#parts] =
  281. parts[#parts] .. ",length="..fragment.duration
  282. end
  283. end
  284. return edl .. table.concat(parts, ";") .. ";"
  285. end
  286. local function has_native_dash_demuxer()
  287. local demuxers = mp.get_property_native("demuxer-lavf-list", {})
  288. for _, v in ipairs(demuxers) do
  289. if v == "dash" then
  290. return true
  291. end
  292. end
  293. return false
  294. end
  295. local function valid_manifest(json)
  296. local reqfmt = json["requested_formats"] and json["requested_formats"][1] or {}
  297. if not reqfmt["manifest_url"] and not json["manifest_url"] then
  298. return false
  299. end
  300. local proto = reqfmt["protocol"] or json["protocol"] or ""
  301. return (proto == "http_dash_segments" and has_native_dash_demuxer()) or
  302. proto:find("^m3u8")
  303. end
  304. local function as_integer(v, def)
  305. def = def or 0
  306. local num = math.floor(tonumber(v) or def)
  307. if num > -math.huge and num < math.huge then
  308. return num
  309. end
  310. return def
  311. end
  312. local function tags_to_edl(json)
  313. local tags = {}
  314. for json_name, mp_name in pairs(tag_list) do
  315. local v = json[json_name]
  316. if v then
  317. tags[#tags + 1] = mp_name .. "=" .. edl_escape(tostring(v))
  318. end
  319. end
  320. if #tags == 0 then
  321. return nil
  322. end
  323. return "!global_tags," .. table.concat(tags, ",")
  324. end
  325. -- Convert a format list from youtube-dl to an EDL URL, or plain URL.
  326. -- json: full json blob by youtube-dl
  327. -- formats: format list by youtube-dl
  328. -- use_all_formats: if=true, then formats is the full format list, and the
  329. -- function will attempt to return them as delay-loaded tracks
  330. -- See res table initialization in the function for result type.
  331. local function formats_to_edl(json, formats, use_all_formats)
  332. local res = {
  333. -- the media URL, which may be EDL
  334. url = nil,
  335. -- for use_all_formats=true: whether any muxed formats are present, and
  336. -- at the same time the separate EDL parts don't have both audio/video
  337. muxed_needed = false,
  338. }
  339. local default_formats = {}
  340. local requested_formats = json["requested_formats"] or json["requested_downloads"]
  341. if use_all_formats and requested_formats then
  342. for _, track in ipairs(requested_formats) do
  343. local id = track["format_id"]
  344. if id then
  345. default_formats[id] = true
  346. end
  347. end
  348. end
  349. local duration = as_integer(json["duration"])
  350. local single_url = nil
  351. local streams = {}
  352. local tbr_only = true
  353. for index, track in ipairs(formats) do
  354. tbr_only = tbr_only and track["tbr"] and
  355. (not track["abr"]) and (not track["vbr"])
  356. end
  357. local has_requested_video = false
  358. local has_requested_audio = false
  359. -- Web players with quality selection always show the highest quality
  360. -- option at the top. Since tracks are usually listed with the first
  361. -- track at the top, that should also be the highest quality track.
  362. -- yt-dlp/youtube-dl sorts it's formats from worst to best.
  363. -- Iterate in reverse to get best track first.
  364. for index = #formats, 1, -1 do
  365. local track = formats[index]
  366. local edl_track = nil
  367. edl_track = edl_track_joined(track.fragments,
  368. track.protocol, json.is_live,
  369. track.fragment_base_url)
  370. if not edl_track and not url_is_safe(track.url) then
  371. msg.error("No safe URL or supported fragmented stream available")
  372. return nil
  373. end
  374. local is_default = default_formats[track["format_id"]]
  375. local tracks = {}
  376. -- "none" means it is not a video
  377. -- nil means it is unknown
  378. if (o.force_all_formats or track.vcodec) and track.vcodec ~= "none" then
  379. tracks[#tracks + 1] = {
  380. media_type = "video",
  381. codec = map_codec_to_mpv(track.vcodec),
  382. }
  383. if is_default then
  384. has_requested_video = true
  385. end
  386. end
  387. if (o.force_all_formats or track.acodec) and track.acodec ~= "none" then
  388. tracks[#tracks + 1] = {
  389. media_type = "audio",
  390. codec = map_codec_to_mpv(track.acodec) or
  391. ext_map[track.ext],
  392. }
  393. if is_default then
  394. has_requested_audio = true
  395. end
  396. end
  397. local url = edl_track or track.url
  398. local hdr = {"!new_stream", "!no_clip", "!no_chapters"}
  399. local skip = #tracks == 0
  400. local params = ""
  401. if use_all_formats then
  402. for _, sub in ipairs(tracks) do
  403. -- A single track that is either audio or video. Delay load it.
  404. local props = ""
  405. if sub.media_type == "video" then
  406. props = props .. ",w=" .. as_integer(track.width)
  407. .. ",h=" .. as_integer(track.height)
  408. .. ",fps=" .. as_integer(track.fps)
  409. elseif sub.media_type == "audio" then
  410. props = props .. ",samplerate=" .. as_integer(track.asr)
  411. end
  412. hdr[#hdr + 1] = "!delay_open,media_type=" .. sub.media_type ..
  413. ",codec=" .. (sub.codec or "null") .. props
  414. -- Add bitrate information etc. for better user selection.
  415. local byterate = 0
  416. local rates = {"tbr", "vbr", "abr"}
  417. if #tracks > 1 then
  418. rates = {({video = "vbr", audio = "abr"})[sub.media_type]}
  419. end
  420. if tbr_only then
  421. rates = {"tbr"}
  422. end
  423. for _, f in ipairs(rates) do
  424. local br = as_integer(track[f])
  425. if br > 0 then
  426. byterate = math.floor(br * 1000 / 8)
  427. break
  428. end
  429. end
  430. local title = track.format or track.format_note or ""
  431. if #tracks > 1 then
  432. if #title > 0 then
  433. title = title .. " "
  434. end
  435. title = title .. "muxed-" .. index
  436. end
  437. local flags = {}
  438. if is_default then
  439. flags[#flags + 1] = "default"
  440. end
  441. hdr[#hdr + 1] = "!track_meta,title=" ..
  442. edl_escape(title) .. ",byterate=" .. byterate ..
  443. iif(#flags > 0, ",flags=" .. table.concat(flags, "+"), "")
  444. end
  445. if duration > 0 then
  446. params = params .. ",length=" .. duration
  447. end
  448. end
  449. if not skip then
  450. hdr[#hdr + 1] = edl_escape(url) .. params
  451. streams[#streams + 1] = table.concat(hdr, ";")
  452. -- In case there is only 1 of these streams.
  453. -- Note: assumes it has no important EDL headers
  454. single_url = url
  455. end
  456. end
  457. -- Merge all tracks into a single virtual file, but avoid EDL if it's
  458. -- only a single track (i.e. redundant).
  459. if #streams == 1 and single_url then
  460. res.url = single_url
  461. elseif #streams > 0 then
  462. local tags = tags_to_edl(json)
  463. if tags then
  464. -- not a stream; just for the sake of concatenating the EDL string
  465. streams[#streams + 1] = tags
  466. end
  467. res.url = "edl://" .. table.concat(streams, ";")
  468. else
  469. return nil
  470. end
  471. if has_requested_audio ~= has_requested_video then
  472. local not_req_prop = has_requested_video and "aid" or "vid"
  473. if mp.get_property(not_req_prop) == "auto" then
  474. mp.set_property("file-local-options/" .. not_req_prop, "no")
  475. end
  476. end
  477. return res
  478. end
  479. local function add_single_video(json)
  480. local streamurl = ""
  481. local format_info = ""
  482. local max_bitrate = 0
  483. local requested_formats = json["requested_formats"] or json["requested_downloads"]
  484. local all_formats = json["formats"]
  485. local has_requested_formats = requested_formats and #requested_formats > 0
  486. local http_headers = has_requested_formats
  487. and requested_formats[1].http_headers
  488. or json.http_headers
  489. if o.use_manifests and valid_manifest(json) then
  490. -- prefer manifest_url if present
  491. format_info = "manifest"
  492. local mpd_url = requested_formats and
  493. requested_formats[1]["manifest_url"] or json["manifest_url"]
  494. if not mpd_url then
  495. msg.error("No manifest URL found in JSON data.")
  496. return
  497. elseif not url_is_safe(mpd_url) then
  498. return
  499. end
  500. streamurl = mpd_url
  501. if requested_formats then
  502. for _, track in pairs(requested_formats) do
  503. max_bitrate = (track.tbr and track.tbr > max_bitrate) and
  504. track.tbr or max_bitrate
  505. end
  506. elseif json.tbr then
  507. max_bitrate = json.tbr > max_bitrate and json.tbr or max_bitrate
  508. end
  509. end
  510. if streamurl == "" then
  511. -- possibly DASH/split tracks
  512. local res = nil
  513. -- Not having requested_formats usually hints to HLS master playlist
  514. -- usage, which we don't want to split off, at least not yet.
  515. if (all_formats and o.all_formats) and
  516. (has_requested_formats or o.force_all_formats)
  517. then
  518. format_info = "all_formats (separate)"
  519. res = formats_to_edl(json, all_formats, true)
  520. -- Note: since we don't delay-load muxed streams, use normal stream
  521. -- selection if we have to use muxed streams.
  522. if res and res.muxed_needed then
  523. res = nil
  524. end
  525. end
  526. if (not res) and has_requested_formats then
  527. format_info = "youtube-dl (separate)"
  528. res = formats_to_edl(json, requested_formats, false)
  529. end
  530. if res then
  531. streamurl = res.url
  532. end
  533. end
  534. if streamurl == "" and json.url then
  535. format_info = "youtube-dl (single)"
  536. local edl_track = nil
  537. edl_track = edl_track_joined(json.fragments, json.protocol,
  538. json.is_live, json.fragment_base_url)
  539. if not edl_track and not url_is_safe(json.url) then
  540. return
  541. end
  542. -- normal video or single track
  543. streamurl = edl_track or json.url
  544. end
  545. if streamurl == "" then
  546. msg.error("No URL found in JSON data.")
  547. return
  548. end
  549. set_http_headers(http_headers)
  550. msg.verbose("format selection: " .. format_info)
  551. msg.debug("streamurl: " .. streamurl)
  552. mp.set_property("stream-open-filename", streamurl:gsub("^data:", "data://", 1))
  553. if mp.get_property("force-media-title", "") == "" then
  554. mp.set_property("file-local-options/force-media-title", json.title)
  555. end
  556. -- set hls-bitrate for dash track selection
  557. if max_bitrate > 0 and
  558. not option_was_set("hls-bitrate") and
  559. not option_was_set_locally("hls-bitrate") then
  560. mp.set_property_native('file-local-options/hls-bitrate', max_bitrate*1000)
  561. end
  562. -- add subtitles
  563. if not (json.requested_subtitles == nil) then
  564. local subs = {}
  565. for lang, info in pairs(json.requested_subtitles) do
  566. subs[#subs + 1] = {lang = lang or "-", info = info}
  567. end
  568. table.sort(subs, function(a, b) return a.lang < b.lang end)
  569. for _, e in ipairs(subs) do
  570. local lang, sub_info = e.lang, e.info
  571. msg.verbose("adding subtitle ["..lang.."]")
  572. local sub = nil
  573. if not (sub_info.data == nil) then
  574. sub = "memory://"..sub_info.data
  575. elseif not (sub_info.url == nil) and
  576. url_is_safe(sub_info.url) then
  577. sub = sub_info.url
  578. end
  579. if not (sub == nil) then
  580. local edl = "edl://!no_clip;!delay_open,media_type=sub"
  581. local codec = map_codec_to_mpv(sub_info.ext)
  582. if codec then
  583. edl = edl .. ",codec=" .. codec
  584. end
  585. edl = edl .. ";" .. edl_escape(sub)
  586. local title = sub_info.name or sub_info.ext
  587. mp.commandv("sub-add", edl, "auto", title, lang)
  588. else
  589. msg.verbose("No subtitle data/url for ["..lang.."]")
  590. end
  591. end
  592. end
  593. -- add chapters
  594. if json.chapters then
  595. msg.debug("Adding pre-parsed chapters")
  596. for i = 1, #json.chapters do
  597. local chapter = json.chapters[i]
  598. local title = chapter.title or ""
  599. if title == "" then
  600. title = string.format('Chapter %02d', i)
  601. end
  602. table.insert(chapter_list, {time=chapter.start_time, title=title})
  603. end
  604. elseif not (json.description == nil) and not (json.duration == nil) then
  605. chapter_list = extract_chapters(json.description, json.duration)
  606. end
  607. -- set start time
  608. if (json.start_time or json.section_start) and
  609. not option_was_set("start") and
  610. not option_was_set_locally("start") then
  611. local start_time = json.start_time or json.section_start
  612. msg.debug("Setting start to: " .. start_time .. " secs")
  613. mp.set_property("file-local-options/start", start_time)
  614. end
  615. -- set end time
  616. if (json.end_time or json.section_end) and
  617. not option_was_set("end") and
  618. not option_was_set_locally("end") then
  619. local end_time = json.end_time or json.section_end
  620. msg.debug("Setting end to: " .. end_time .. " secs")
  621. mp.set_property("file-local-options/end", end_time)
  622. end
  623. -- set aspect ratio for anamorphic video
  624. if not (json.stretched_ratio == nil) and
  625. not option_was_set("video-aspect-override") then
  626. mp.set_property('file-local-options/video-aspect-override', json.stretched_ratio)
  627. end
  628. local stream_opts = mp.get_property_native("file-local-options/stream-lavf-o", {})
  629. -- for rtmp
  630. if (json.protocol == "rtmp") then
  631. stream_opts = append_libav_opt(stream_opts,
  632. "rtmp_tcurl", streamurl)
  633. stream_opts = append_libav_opt(stream_opts,
  634. "rtmp_pageurl", json.page_url)
  635. stream_opts = append_libav_opt(stream_opts,
  636. "rtmp_playpath", json.play_path)
  637. stream_opts = append_libav_opt(stream_opts,
  638. "rtmp_swfverify", json.player_url)
  639. stream_opts = append_libav_opt(stream_opts,
  640. "rtmp_swfurl", json.player_url)
  641. stream_opts = append_libav_opt(stream_opts,
  642. "rtmp_app", json.app)
  643. end
  644. if json.proxy and json.proxy ~= "" then
  645. stream_opts = append_libav_opt(stream_opts,
  646. "http_proxy", json.proxy)
  647. end
  648. mp.set_property_native("file-local-options/stream-lavf-o", stream_opts)
  649. end
  650. local function check_version(ytdl_path)
  651. local command = {
  652. name = "subprocess",
  653. capture_stdout = true,
  654. args = {ytdl_path, "--version"}
  655. }
  656. local version_string = mp.command_native(command).stdout
  657. local year, month, day = string.match(version_string, "(%d+).(%d+).(%d+)")
  658. -- sanity check
  659. if (tonumber(year) < 2000) or (tonumber(month) > 12) or
  660. (tonumber(day) > 31) then
  661. return
  662. end
  663. local version_ts = os.time{year=year, month=month, day=day}
  664. if (os.difftime(os.time(), version_ts) > 60*60*24*90) then
  665. msg.warn("It appears that your youtube-dl version is severely out of date.")
  666. end
  667. end
  668. function run_ytdl_hook(url)
  669. local start_time = os.clock()
  670. -- strip ytdl://
  671. if (url:find("ytdl://") == 1) then
  672. url = url:sub(8)
  673. end
  674. local format = mp.get_property("options/ytdl-format")
  675. local raw_options = mp.get_property_native("options/ytdl-raw-options")
  676. local allsubs = true
  677. local proxy = nil
  678. local use_playlist = false
  679. local command = {
  680. ytdl.path, "--no-warnings", "-J", "--flat-playlist",
  681. "--sub-format", "ass/srt/best"
  682. }
  683. -- Checks if video option is "no", change format accordingly,
  684. -- but only if user didn't explicitly set one
  685. if (mp.get_property("options/vid") == "no") and (#format == 0) then
  686. format = "bestaudio/best"
  687. msg.verbose("Video disabled. Only using audio")
  688. end
  689. if (format == "") then
  690. format = "bestvideo+bestaudio/best"
  691. end
  692. if format ~= "ytdl" then
  693. table.insert(command, "--format")
  694. table.insert(command, format)
  695. end
  696. for param, arg in pairs(raw_options) do
  697. table.insert(command, "--" .. param)
  698. if (arg ~= "") then
  699. table.insert(command, arg)
  700. end
  701. if (param == "sub-lang" or param == "sub-langs" or param == "srt-lang") and (arg ~= "") then
  702. allsubs = false
  703. elseif (param == "proxy") and (arg ~= "") then
  704. proxy = arg
  705. elseif (param == "yes-playlist") then
  706. use_playlist = true
  707. end
  708. end
  709. if (allsubs == true) then
  710. table.insert(command, "--all-subs")
  711. end
  712. if not use_playlist then
  713. table.insert(command, "--no-playlist")
  714. end
  715. table.insert(command, "--")
  716. table.insert(command, url)
  717. local result
  718. if ytdl.searched then
  719. result = exec(command)
  720. else
  721. local separator = platform_is_windows() and ";" or ":"
  722. if o.ytdl_path:match("[^" .. separator .. "]") then
  723. ytdl.paths_to_search = {}
  724. for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do
  725. table.insert(ytdl.paths_to_search, path)
  726. end
  727. end
  728. for _, path in pairs(ytdl.paths_to_search) do
  729. -- search for youtube-dl in mpv's config dir
  730. local exesuf = platform_is_windows() and ".exe" or ""
  731. local ytdl_cmd = mp.find_config_file(path .. exesuf)
  732. if ytdl_cmd then
  733. msg.verbose("Found youtube-dl at: " .. ytdl_cmd)
  734. ytdl.path = ytdl_cmd
  735. command[1] = ytdl.path
  736. result = exec(command)
  737. break
  738. else
  739. msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories")
  740. command[1] = path
  741. result = exec(command)
  742. if result.error_string == "init" then
  743. msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions")
  744. else
  745. msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH")
  746. ytdl.path = path
  747. break
  748. end
  749. end
  750. end
  751. ytdl.searched = true
  752. end
  753. if result.killed_by_us then
  754. return
  755. end
  756. local json = result.stdout
  757. local parse_err = nil
  758. if result.status ~= 0 or json == "" then
  759. json = nil
  760. elseif json then
  761. json, parse_err = utils.parse_json(json)
  762. end
  763. if (json == nil) then
  764. msg.verbose("status:", result.status)
  765. msg.verbose("reason:", result.error_string)
  766. msg.verbose("stdout:", result.stdout)
  767. msg.verbose("stderr:", result.stderr)
  768. -- trim our stderr to avoid spurious newlines
  769. ytdl_err = result.stderr:gsub("^%s*(.-)%s*$", "%1")
  770. msg.error(ytdl_err)
  771. local err = "youtube-dl failed: "
  772. if result.error_string and result.error_string == "init" then
  773. err = err .. "not found or not enough permissions"
  774. elseif parse_err then
  775. err = err .. "failed to parse JSON data: " .. parse_err
  776. else
  777. err = err .. "unexpected error occurred"
  778. end
  779. msg.error(err)
  780. if parse_err or string.find(ytdl_err, "yt%-dl%.org/bug") then
  781. check_version(ytdl.path)
  782. end
  783. return
  784. end
  785. msg.verbose("youtube-dl succeeded!")
  786. msg.debug('ytdl parsing took '..os.clock()-start_time..' seconds')
  787. json["proxy"] = json["proxy"] or proxy
  788. -- what did we get?
  789. if json["direct"] then
  790. -- direct URL, nothing to do
  791. msg.verbose("Got direct URL")
  792. return
  793. elseif (json["_type"] == "playlist")
  794. or (json["_type"] == "multi_video") then
  795. -- a playlist
  796. if (#json.entries == 0) then
  797. msg.warn("Got empty playlist, nothing to play.")
  798. return
  799. end
  800. local self_redirecting_url =
  801. json.entries[1]["_type"] ~= "url_transparent" and
  802. json.entries[1]["webpage_url"] and
  803. json.entries[1]["webpage_url"] == json["webpage_url"]
  804. -- some funky guessing to detect multi-arc videos
  805. if self_redirecting_url and #json.entries > 1
  806. and json.entries[1].protocol == "m3u8_native"
  807. and json.entries[1].url then
  808. msg.verbose("multi-arc video detected, building EDL")
  809. local playlist = edl_track_joined(json.entries)
  810. msg.debug("EDL: " .. playlist)
  811. if not playlist then
  812. return
  813. end
  814. -- can't change the http headers for each entry, so use the 1st
  815. set_http_headers(json.entries[1].http_headers)
  816. mp.set_property("stream-open-filename", playlist)
  817. if json.title and mp.get_property("force-media-title", "") == "" then
  818. mp.set_property("file-local-options/force-media-title",
  819. json.title)
  820. end
  821. -- there might not be subs for the first segment
  822. local entry_wsubs = nil
  823. for i, entry in pairs(json.entries) do
  824. if not (entry.requested_subtitles == nil) then
  825. entry_wsubs = i
  826. break
  827. end
  828. end
  829. if not (entry_wsubs == nil) and
  830. not (json.entries[entry_wsubs].duration == nil) then
  831. for j, req in pairs(json.entries[entry_wsubs].requested_subtitles) do
  832. local subfile = "edl://"
  833. for i, entry in pairs(json.entries) do
  834. if not (entry.requested_subtitles == nil) and
  835. not (entry.requested_subtitles[j] == nil) and
  836. url_is_safe(entry.requested_subtitles[j].url) then
  837. subfile = subfile..edl_escape(entry.requested_subtitles[j].url)
  838. else
  839. subfile = subfile..edl_escape("memory://WEBVTT")
  840. end
  841. subfile = subfile..",length="..entry.duration..";"
  842. end
  843. msg.debug(j.." sub EDL: "..subfile)
  844. mp.commandv("sub-add", subfile, "auto", req.ext, j)
  845. end
  846. end
  847. elseif self_redirecting_url and #json.entries == 1 then
  848. msg.verbose("Playlist with single entry detected.")
  849. add_single_video(json.entries[1])
  850. else
  851. local playlist_index = parse_yt_playlist(url, json)
  852. local playlist = {"#EXTM3U"}
  853. for i, entry in pairs(json.entries) do
  854. local site = entry.url
  855. local title = entry.title
  856. if not (title == nil) then
  857. title = string.gsub(title, '%s+', ' ')
  858. table.insert(playlist, "#EXTINF:0," .. title)
  859. end
  860. --[[ some extractors will still return the full info for
  861. all clips in the playlist and the URL will point
  862. directly to the file in that case, which we don't
  863. want so get the webpage URL instead, which is what
  864. we want, but only if we aren't going to trigger an
  865. infinite loop
  866. --]]
  867. if entry["webpage_url"] and not self_redirecting_url then
  868. site = entry["webpage_url"]
  869. end
  870. -- links without protocol as returned by --flat-playlist
  871. if not site:find("://") then
  872. -- youtube extractor provides only IDs,
  873. -- others come prefixed with the extractor name and ":"
  874. local prefix = site:find(":") and "ytdl://" or
  875. "https://youtu.be/"
  876. table.insert(playlist, prefix .. site)
  877. elseif url_is_safe(site) then
  878. table.insert(playlist, site)
  879. end
  880. end
  881. if use_playlist and
  882. not option_was_set("playlist-start") and playlist_index then
  883. mp.set_property_number("playlist-start", playlist_index)
  884. end
  885. mp.set_property("stream-open-filename", "memory://" .. table.concat(playlist, "\n"))
  886. end
  887. else -- probably a video
  888. add_single_video(json)
  889. end
  890. msg.debug('script running time: '..os.clock()-start_time..' seconds')
  891. end
  892. if (not o.try_ytdl_first) then
  893. mp.add_hook("on_load", 10, function ()
  894. msg.verbose('ytdl:// hook')
  895. local url = mp.get_property("stream-open-filename", "")
  896. if not (url:find("ytdl://") == 1) then
  897. msg.verbose('not a ytdl:// url')
  898. return
  899. end
  900. run_ytdl_hook(url)
  901. end)
  902. end
  903. mp.add_hook(o.try_ytdl_first and "on_load" or "on_load_fail", 10, function()
  904. msg.verbose('full hook')
  905. local url = mp.get_property("stream-open-filename", "")
  906. if not (url:find("ytdl://") == 1) and
  907. not ((url:find("https?://") == 1) and not is_blacklisted(url)) then
  908. return
  909. end
  910. run_ytdl_hook(url)
  911. end)
  912. mp.add_hook("on_preloaded", 10, function ()
  913. if next(chapter_list) ~= nil then
  914. msg.verbose("Setting chapters")
  915. mp.set_property_native("chapter-list", chapter_list)
  916. chapter_list = {}
  917. end
  918. end)