legacy-dots

ytdl_hook.lua

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