-- ~/.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)