487 lines
13 KiB
Lua
487 lines
13 KiB
Lua
--[[
|
|
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("<entry>(.-)</entry>") do
|
|
local id = entry:match("<yt:videoId>(.-)</yt:videoId>")
|
|
local title = entry:match("<title>(.-)</title>")
|
|
local author = entry:match("<author>.-<name>(.-)</name>")
|
|
local published = entry:match("<published>(.-)</published>")
|
|
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)
|