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",
-- nombre de vidéos à charger
per_channel = 3,
max_videos = 150,
channel_videos = 400,
-- live check twitch (ne fonctionne pas pour le moment)
twitch_live_check = true,
}
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
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
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)