281 lines
9.7 KiB
Lua
281 lines
9.7 KiB
Lua
-- ~/.config/mpv/scripts/notify.lua
|
||
|
||
local mp = require 'mp'
|
||
local utils = require 'mp.utils'
|
||
local HOME = os.getenv("HOME") or ""
|
||
|
||
local function read_api_key(name)
|
||
local user_path = HOME .. "/.config/mpv/apikeys/" .. name
|
||
local system_path = "/etc/mpv/apikeys/" .. name
|
||
|
||
local file = io.open(user_path, "r") or io.open(system_path, "r")
|
||
if file then
|
||
local key = file:read("*l")
|
||
file:close()
|
||
return key
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local LASTFM_API_KEY = read_api_key("lastfm")
|
||
local TMDB_API_KEY = read_api_key("tmdb")
|
||
|
||
local OLLAMA_URL = "http://localhost:11434/api/generate"
|
||
|
||
local HOME = os.getenv("HOME")
|
||
local pattern_cache, pattern_mtimes = {}, {}
|
||
|
||
local function log(tag, msg)
|
||
mp.msg.info("[" .. tag .. "] " .. tostring(msg))
|
||
end
|
||
|
||
local function url_encode(str)
|
||
return str:gsub("([^%w _%%%-%.~])", function(c)
|
||
return string.format("%%%02X", string.byte(c))
|
||
end):gsub(" ", "%%20")
|
||
end
|
||
|
||
local function file_mtime(path)
|
||
local stat = utils.file_info(path)
|
||
return stat and stat.modify or 0
|
||
end
|
||
|
||
local function watch_patterns(file_path)
|
||
local current_mtime = file_mtime(file_path)
|
||
if not pattern_mtimes[file_path] or pattern_mtimes[file_path] < current_mtime then
|
||
local file = io.open(file_path, "r")
|
||
if not file then return {} end
|
||
local patterns = {}
|
||
for line in file:lines() do
|
||
line = line:match("^%s*(.-)%s*$")
|
||
if line ~= "" and not line:match("^#") then
|
||
table.insert(patterns, line)
|
||
-- log(" Pattern loaded", line)
|
||
end
|
||
end
|
||
file:close()
|
||
pattern_cache[file_path] = patterns
|
||
pattern_mtimes[file_path] = current_mtime
|
||
end
|
||
return pattern_cache[file_path] or {}
|
||
end
|
||
|
||
local function apply_patterns(text, patterns)
|
||
for _, pat in ipairs(patterns) do
|
||
local lower = text:lower()
|
||
local low_pat = pat:lower()
|
||
local new = lower:gsub(low_pat, "")
|
||
if lower ~= new then
|
||
log(" Pattern applied", pat)
|
||
text = text:gsub(pat, "")
|
||
end
|
||
end
|
||
return text:gsub("[%.:_%-]", " "):gsub("%s+", " "):gsub("^%s*(.-)%s*$", "%1")
|
||
end
|
||
|
||
local function get_pattern_path(filename)
|
||
local user_path = HOME .. "/.config/mpv/filters/" .. filename
|
||
if utils.file_info(user_path) then
|
||
return user_path
|
||
end
|
||
return "/usr/share/mpv/scripts/notify/" .. filename
|
||
end
|
||
|
||
local function clean_artist_name(artist)
|
||
return apply_patterns(artist or "", watch_patterns(get_pattern_path("patterns_artist.txt")))
|
||
end
|
||
|
||
local function clean_title_common(title)
|
||
return apply_patterns(title or "", watch_patterns(get_pattern_path("patterns_common.txt")))
|
||
end
|
||
|
||
|
||
local function classify_media()
|
||
local tracks = mp.get_property_native("track-list") or {}
|
||
local has_audio, has_real_video = false, false
|
||
for _, track in ipairs(tracks) do
|
||
if track.type == "audio" and track.selected then
|
||
has_audio = true
|
||
elseif track.type == "video" and track.selected and not (track.codec and track.codec:match("mjpeg")) then
|
||
has_real_video = true
|
||
end
|
||
end
|
||
if has_real_video then
|
||
return "video"
|
||
elseif has_audio then
|
||
return "music"
|
||
end
|
||
return "unknown"
|
||
end
|
||
|
||
local function find_local_cover()
|
||
local path = mp.get_property("path")
|
||
if not path then return nil end
|
||
local dir, file = utils.split_path(path)
|
||
local base = file:match("^(.-)%.%w+$") or file
|
||
local suffixes = { "-poster.jpg", "-landscape.jpg", "-logo.png" }
|
||
for _, suffix in ipairs(suffixes) do
|
||
local candidate = utils.join_path(dir, base .. suffix)
|
||
if utils.file_info(candidate) then return candidate end
|
||
end
|
||
local files = utils.readdir(dir, "files") or {}
|
||
for _, f in ipairs(files) do
|
||
if f:lower():match("cover%.jpe?g") or f:lower():match("folder%.jpe?g") or f:lower():match("front%.jpe?g") then
|
||
return dir .. "/" .. f
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function notify_with_image(title, msg, image_path)
|
||
local tmp_path = "/tmp/mpv_art.jpg"
|
||
if image_path:match("^https?://") then
|
||
local dl = utils.subprocess({ args = { "curl", "-s", "-L", "-o", tmp_path, image_path } })
|
||
if dl.status == 0 then image_path = tmp_path end
|
||
end
|
||
utils.subprocess({ args = { "notify-send", title, msg, "--icon=" .. image_path } })
|
||
end
|
||
|
||
local function fetch_tmdb_poster(title)
|
||
title = clean_title_common(title)
|
||
local url = string.format("https://api.themoviedb.org/3/search/movie?api_key=%s&query=%s", TMDB_API_KEY,
|
||
url_encode(title))
|
||
local res = utils.subprocess({ args = { "curl", "-s", url } })
|
||
if res.status == 0 then
|
||
local data = utils.parse_json(res.stdout)
|
||
if data and data.results and data.results[1] and data.results[1].poster_path then
|
||
return "https://image.tmdb.org/t/p/w500" .. data.results[1].poster_path
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function fetch_lastfm_cover(title, artist)
|
||
if not artist or artist == "" then return nil end
|
||
title = clean_title_common(title)
|
||
local url = string.format(
|
||
"https://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key=%s&artist=%s&track=%s&format=json", LASTFM_API_KEY,
|
||
url_encode(artist), url_encode(title))
|
||
log("Last.fm getInfo URL", url)
|
||
local res = utils.subprocess({ args = { "curl", "-s", url } })
|
||
if res.status == 0 then
|
||
local data = utils.parse_json(res.stdout)
|
||
if data and data.track and data.track.album and data.track.album.image then
|
||
for _, img in ipairs(data.track.album.image) do
|
||
if img.size == "extralarge" and img["#text"] ~= "" then
|
||
return img["#text"]
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function infer_music_metadata(title, artist, metadata)
|
||
local prompt = string.format([[File: "%s"
|
||
Metadata detected:
|
||
Artist: %s
|
||
Album: %s
|
||
|
||
Based on this, return a JSON: { "artist": "...", "title": "..." }
|
||
do NOT explain anything. do NOT return quotation nor additional text.
|
||
]], title, artist or "", metadata.album or "")
|
||
log(" Prompt sent to LLM (music)", prompt)
|
||
local payload = utils.format_json({ model = "gemma2:2b", prompt = prompt, stream = false })
|
||
local res = utils.subprocess({ args = { "curl", "-s", "-X", "POST", OLLAMA_URL, "-H", "Content-Type: application/json", "-d", payload } })
|
||
if not res or res.status ~= 0 then return "", title end
|
||
local outer = utils.parse_json(res.stdout)
|
||
if outer and outer.response then
|
||
local clean = outer.response:gsub("```json", ""):gsub("```", ""):gsub("\n", ""):gsub("\\", "")
|
||
local parsed = utils.parse_json(clean)
|
||
if parsed then return parsed.artist or "", parsed.title or title end
|
||
end
|
||
return "", title
|
||
end
|
||
|
||
local function safe_infer_metadata(title, artist, metadata)
|
||
local inferred_artist, inferred_title = infer_music_metadata(title, artist, metadata)
|
||
if not inferred_title or inferred_title:lower():match("unknown") then inferred_title = title end
|
||
local trusted_artist = artist
|
||
if artist == "" or artist:lower():match("semal") or artist:lower():match("official") then
|
||
trusted_artist = inferred_artist
|
||
elseif inferred_artist:lower() == artist:lower() then
|
||
trusted_artist = inferred_artist
|
||
elseif artist:lower():find(inferred_artist:lower(), 1, true) then
|
||
trusted_artist = inferred_artist
|
||
else
|
||
log("️ Inference ignored: artist not confiable", inferred_artist)
|
||
end
|
||
trusted_artist = clean_artist_name(trusted_artist)
|
||
return trusted_artist, inferred_title
|
||
end
|
||
|
||
local function infer_title_with_ollama(title)
|
||
if not title or title == "" then return title end
|
||
local prompt = string.format([[Archivo: "%s"
|
||
Return only a JSON: { "title": "..." }
|
||
do NOT explain anything. do NOT return anything else.
|
||
]], title)
|
||
log(" Prompt enviado a Ollama (video)", prompt)
|
||
local payload = utils.format_json({ model = "gemma2:2b", prompt = prompt, stream = false })
|
||
local res = utils.subprocess({ args = { "curl", "-s", "-X", "POST", OLLAMA_URL, "-H", "Content-Type: application/json", "-d", payload } })
|
||
if res.status ~= 0 then return title end
|
||
local outer = utils.parse_json(res.stdout)
|
||
if outer and outer.response then
|
||
local clean = outer.response:gsub("```json", ""):gsub("```", ""):gsub("\n", ""):gsub("\\", "")
|
||
local parsed = utils.parse_json(clean)
|
||
if parsed and parsed.title then return parsed.title end
|
||
end
|
||
return title
|
||
end
|
||
|
||
local function on_file_loaded()
|
||
local media_type = classify_media()
|
||
local metadata = mp.get_property_native("metadata") or {}
|
||
local raw_title = metadata.title or metadata.album or mp.get_property("media-title") or mp.get_property("filename") or
|
||
"Unknown"
|
||
local raw_artist = mp.get_property("metadata/by-key/artist") or ""
|
||
if raw_artist:find(",") then raw_artist = raw_artist:match("([^,]+)") end
|
||
local artist = clean_artist_name(raw_artist)
|
||
|
||
log(" Type of media", media_type)
|
||
log(" Artist detected", artist)
|
||
log(" raw title", raw_title)
|
||
|
||
local local_cover = find_local_cover()
|
||
if local_cover then
|
||
log("️ Local cover detected", local_cover)
|
||
notify_with_image(media_type == "video" and " Video" or " " .. (artist ~= "" and artist or "Music"),
|
||
clean_title_common(raw_title), local_cover)
|
||
return
|
||
end
|
||
|
||
if media_type == "music" then
|
||
artist, raw_title = safe_infer_metadata(raw_title, raw_artist, metadata)
|
||
elseif media_type == "video" then
|
||
raw_title = infer_title_with_ollama(raw_title)
|
||
end
|
||
|
||
local clean_title = clean_title_common(raw_title)
|
||
|
||
if media_type == "music" then
|
||
local cover = fetch_lastfm_cover(clean_title, artist)
|
||
if cover then
|
||
notify_with_image(" " .. (artist ~= "" and artist or "Música"), clean_title, cover)
|
||
return
|
||
end
|
||
elseif media_type == "video" then
|
||
local poster = fetch_tmdb_poster(clean_title)
|
||
if poster then
|
||
log("️ Poster TMDB detectado", poster)
|
||
notify_with_image(" Video", clean_title, poster)
|
||
return
|
||
end
|
||
end
|
||
|
||
notify_with_image(" " .. (artist ~= "" and artist or "Desconocido"), clean_title, "/tmp/default_cover.jpg")
|
||
end
|
||
|
||
mp.observe_property("metadata", "native", function(_, _) on_file_loaded() end)
|
||
mp.observe_property("metadata/by-key/icy-title", "string", on_stream_title_change)
|