--[[ feeds.lua — menu "Abonnements YouTube / Twitch" pour mpv, rendu via uosc. Lit deux CSV : - le subscriptions.csv de Google Takeout (col. 1 = ID de chaîne, col. 3 = nom de la chaîne) - un twitch.csv maison (col. 1 = login Twitch, col. 2 = libellé, optionnelle) Trois sources : - Abonnements YouTube : dernières vidéos, via les flux RSS publics (curl) - Parcourir une chaîne : catalogue d'une chaîne, via yt-dlp - Twitch : chaînes suivies, lecture via streamlink Aucune clé API n'est nécessaire. Dépendances : uosc, curl, yt-dlp, streamlink. Raccourci par défaut : alt+h --]] local utils = require("mp.utils") local options = require("mp.options") local msg = require("mp.msg") local o = { youtube_csv = "~/nixos-config/dotfiles/mpv/youtube.csv", twitch_csv = "~/nixos-config/dotfiles/mpv/twitch.csv", key = "alt+h", per_channel = 3, -- nb max de vidéos gardées par chaîne (flux RSS) max_videos = 150, -- taille max de la liste des abonnements channel_videos = 1000, -- nb de vidéos chargées en parcourant une chaîne twitch_live_check = true, -- détection "en direct" (best-effort, sans clé API) } options.read_options(o, "feeds") local cache = { youtube = nil, twitch = nil, channels = {} } local function expand(path) return mp.command_native({ "expand-path", path }) end local function unescape(s) if not s then return "" end s = s:gsub("<", "<"):gsub(">", ">"):gsub(""", '"') s = s:gsub("'", "'"):gsub("'", "'"):gsub("&", "&") return s end local function trim(s) return (s or ""):gsub("^%s+", ""):gsub("%s+$", "") end local function format_duration(sec) if type(sec) ~= "number" or sec <= 0 then return "" end sec = math.floor(sec) local h = math.floor(sec / 3600) local m = math.floor((sec % 3600) / 60) local s = sec % 60 if h > 0 then return string.format("%d:%02d:%02d", h, m, s) end return string.format("%d:%02d", m, s) end local function open_menu(m) mp.commandv("script-message-to", "uosc", "open-menu", utils.format_json(m)) end local function update_menu(m) mp.commandv("script-message-to", "uosc", "update-menu", utils.format_json(m)) end local function loading_menu(type_, title) open_menu({ type = type_, title = title, items = { { title = "Chargement…", icon = "spinner", selectable = false, keep_open = true } }, }) end -- Renvoie les lignes du fichier, débarrassées d'un éventuel \r final (les -- exports Takeout peuvent être en CRLF). local function read_lines(path) local lines = {} local full = expand(path) local f = io.open(full, "r") if not f then msg.warn("CSV illisible : " .. tostring(full)) return lines end for line in f:lines() do lines[#lines + 1] = (line:gsub("\r$", "")) end f:close() return lines end --------------------------------------------------- Lecture mp.register_script_message("feeds-play", function(url) mp.commandv("loadfile", url, "replace") end) mp.register_script_message("feeds-twitch", function(login) mp.osd_message("Ouverture de " .. login .. " via streamlink…", 3) mp.command_native_async({ name = "subprocess", args = { "streamlink", "--player=mpv", "--twitch-low-latency", "twitch.tv/" .. login, "best" }, detach = true, -- sans ceci, mpv tue le processus dès qu'aucune vidéo n'est en lecture playback_only = false, }, function() end) end) --------------------------------------------------- CSV YouTube -- Format Takeout : ID,URL,Titre — l'URL ne contient jamais de virgule, donc -- tout ce qui suit la 2e virgule est le titre (éventuellement entre -- guillemets s'il contient lui-même une virgule). local function read_youtube_channels() local channels = {} for _, line in ipairs(read_lines(o.youtube_csv)) do local id = line:match("^([%w_-]+)") if id and id:match("^UC") then -- écarte l'en-tête du CSV local title = line:match("^[^,]*,[^,]*,(.*)$") or "" title = trim(title) local inner = title:match('^"(.*)"$') if inner then title = inner:gsub('""', '"') end if title == "" then title = id end channels[#channels + 1] = { id = id, title = title } end end table.sort(channels, function(a, b) return a.title:lower() < b.title:lower() end) return channels end --------------------------------------------------- Abonnements (RSS) local function youtube_menu_data(videos) local items = {} for _, v in ipairs(videos) do items[#items + 1] = { title = v.author .. " — " .. v.title, hint = v.published:sub(1, 10), value = { "script-message", "feeds-play", v.url }, } end if #items == 0 then items = { { title = "Aucune vidéo récupérée — voir les logs (--msg-level=feeds=info)", selectable = false, }, } end return { type = "yt_feed", title = "Abonnements YouTube", items = items } end local function parse_feeds(blob) local videos, seen_count = {}, {} for entry in blob:gmatch("(.-)") do local id = entry:match("(.-)") local title = entry:match("(.-)") local author = entry:match(".-(.-)") local published = entry:match("(.-)") if id and title and published then videos[#videos + 1] = { url = "https://www.youtube.com/watch?v=" .. id, title = unescape(title), author = unescape(author or "?"), published = published, } end end -- les dates ISO 8601 se trient correctement comme de simples chaînes table.sort(videos, function(a, b) return a.published > b.published end) local kept = {} for _, v in ipairs(videos) do local n = (seen_count[v.author] or 0) if n < o.per_channel then seen_count[v.author] = n + 1 kept[#kept + 1] = v if #kept >= o.max_videos then break end end end return kept end local function youtube_menu(force) if cache.youtube and not force then open_menu(youtube_menu_data(cache.youtube)) return end local channels = read_youtube_channels() local args = { "curl", "-s", "--parallel", "--parallel-max", "16", "-m", "25" } for _, c in ipairs(channels) do args[#args + 1] = "https://www.youtube.com/feeds/videos.xml?channel_id=" .. c.id end if #channels == 0 then open_menu({ type = "yt_feed", title = "Abonnements YouTube", items = { { title = "Aucune chaîne lue dans " .. o.youtube_csv, selectable = false } }, }) return end msg.info(#channels .. " chaînes à interroger") loading_menu("yt_feed", "Abonnements YouTube") mp.command_native_async({ name = "subprocess", args = args, capture_stdout = true, playback_only = false, }, function(_, res) local videos = {} if res and res.stdout and #res.stdout > 0 then videos = parse_feeds(res.stdout) end msg.info( string.format( "curl status=%s, %d octets, %d vidéos", tostring(res and res.status), res and res.stdout and #res.stdout or 0, #videos ) ) cache.youtube = videos update_menu(youtube_menu_data(videos)) end) end --------------------------------------------------- Parcourir une chaîne local function channel_videos_menu_data(name, videos) local items = {} for _, v in ipairs(videos) do items[#items + 1] = { title = v.title, hint = v.duration, value = { "script-message", "feeds-play", v.url }, } end if #items == 0 then items = { { title = "Rien récupéré — voir les logs (--msg-level=feeds=info)", selectable = false } } end return { type = "yt_channel", title = name, items = items } end local function channel_videos_menu(id, name) if cache.channels[id] then open_menu(channel_videos_menu_data(name, cache.channels[id])) return end loading_menu("yt_channel", name) mp.command_native_async({ name = "subprocess", args = { "yt-dlp", "--flat-playlist", "--dump-single-json", "--no-warnings", "--playlist-items", "1:" .. tostring(o.channel_videos), "https://www.youtube.com/channel/" .. id .. "/videos", }, capture_stdout = true, playback_only = false, }, function(_, res) local videos = {} if res and res.stdout and #res.stdout > 0 then local ok, data = pcall(utils.parse_json, res.stdout) if ok and data and data.entries then for _, e in ipairs(data.entries) do if e.id then videos[#videos + 1] = { title = e.title or e.id, duration = format_duration(e.duration), url = "https://www.youtube.com/watch?v=" .. e.id, } end end end end msg.info( string.format( "yt-dlp status=%s, %d octets, %d vidéos (%s)", tostring(res and res.status), res and res.stdout and #res.stdout or 0, #videos, name ) ) cache.channels[id] = videos update_menu(channel_videos_menu_data(name, videos)) end) end mp.register_script_message("feeds-channel", function(id, name) channel_videos_menu(id, name) end) local function channels_menu() local channels = read_youtube_channels() local items = {} for _, c in ipairs(channels) do items[#items + 1] = { title = c.title, value = { "script-message", "feeds-channel", c.id, c.title }, } end if #items == 0 then items = { { title = "Aucune chaîne lue dans " .. o.youtube_csv, selectable = false } } end -- tape simplement quelques lettres pour filtrer la liste open_menu({ type = "yt_channels", title = "Parcourir une chaîne", items = items }) end --------------------------------------------------- Twitch local function twitch_menu_data(channels) local items = {} for _, c in ipairs(channels) do local mark = "" if c.live == true then mark = "🔴 " elseif c.live == false then mark = "· " end items[#items + 1] = { title = mark .. (c.label ~= "" and c.label or c.login), hint = c.live == true and "en direct" or nil, value = { "script-message", "feeds-twitch", c.login }, } end if #items == 0 then items = { { title = "Aucune chaîne lue dans " .. o.twitch_csv, selectable = false } } end return { type = "twitch_feed", title = "Twitch", items = items } end local function twitch_menu(force) if cache.twitch and not force then open_menu(twitch_menu_data(cache.twitch)) return end local channels = {} for _, line in ipairs(read_lines(o.twitch_csv)) do local login = line:match("^([%w_][%w_]*)") if login and login:lower() ~= "login" then -- ignore l'en-tête éventuel local label = trim(line:match("^[^,]*,(.*)$") or "") channels[#channels + 1] = { login = login, label = label, live = nil } end end if #channels == 0 or not o.twitch_live_check then cache.twitch = channels open_menu(twitch_menu_data(channels)) return end loading_menu("twitch_feed", "Twitch") local logins = {} for _, c in ipairs(channels) do logins[#logins + 1] = c.login end local script = [[ for c in ]] .. table.concat(logins, " ") .. [[; do ( if curl -sL -m 8 "https://www.twitch.tv/$c" | grep -q '"isLiveBroadcast":true'; then echo "$c live" else echo "$c off" fi ) & done wait ]] mp.command_native_async({ name = "subprocess", args = { "sh", "-c", script }, capture_stdout = true, playback_only = false, }, function(_, res) local nb_live = 0 if res and res.stdout and #res.stdout > 0 then local status = {} for login, state in res.stdout:gmatch("(%S+)%s+(%S+)") do status[login] = (state == "live") end for _, c in ipairs(channels) do if status[c.login] ~= nil then c.live = status[c.login] if c.live then nb_live = nb_live + 1 end end end table.sort(channels, function(a, b) if a.live ~= b.live then return a.live == true end return a.login < b.login end) end msg.info( string.format( "twitch status=%s, %d chaînes, %d en direct", tostring(res and res.status), #channels, nb_live ) ) cache.twitch = channels update_menu(twitch_menu_data(channels)) end) end --------------------------------------------------- Menu racine mp.register_script_message("feeds-open", function(which, force) local refresh = (force == "refresh") if which == "youtube" then youtube_menu(refresh) elseif which == "channels" then channels_menu() elseif which == "twitch" then twitch_menu(refresh) end end) mp.add_key_binding(o.key, "feeds-menu", function() open_menu({ type = "feeds", title = "Chaînes", items = { { title = "Abonnements YouTube", icon = "subscriptions", value = { "script-message", "feeds-open", "youtube" }, }, { title = "Parcourir une chaîne", icon = "video_library", value = { "script-message", "feeds-open", "channels" }, }, { title = "Twitch", icon = "live_tv", value = { "script-message", "feeds-open", "twitch" }, }, { title = "Rafraîchir YouTube", icon = "refresh", muted = true, value = { "script-message", "feeds-open", "youtube", "refresh" }, }, { title = "Rafraîchir Twitch", icon = "refresh", muted = true, value = { "script-message", "feeds-open", "twitch", "refresh" }, }, }, }) end)