From b25ebf7ca2249b6e468b564616cdab40d71b8979 Mon Sep 17 00:00:00 2001 From: teraflops Date: Thu, 29 May 2025 18:57:13 +0200 Subject: [PATCH] initial commit --- LICENSE | 22 +++ README.md | 65 +++++++ etc/mpv/filters/patterns_artist.txt | 5 + etc/mpv/filters/patterns_common.txt | 80 ++++++++ etc/mpv/scripts/notify.lua | 280 ++++++++++++++++++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 etc/mpv/filters/patterns_artist.txt create mode 100644 etc/mpv/filters/patterns_common.txt create mode 100644 etc/mpv/scripts/notify.lua diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b89984e --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Teraflops + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..266f536 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# mpvcovergrabber + +`mpvcovergrabber` is a script for [MPV](https://mpv.io) that shows rich notifications with album covers or movie posters when playing audio or video files. It uses local inference via language models such as `gemma2:2b` through [Ollama](https://ollama.com) and APIs like Last.fm and TMDB. + +## Features + +- Metadata inference using LLMs (via Ollama) +- Cover retrieval from: + - Local files + - Last.fm (for music) + - TMDB (for movies) +- Metadata cleanup using customizable regex patterns +- Support for audio streams + +## Installation + +You can install it form the AUR: + +```bash +yay -S mpvcovergrabber-git +``` +or +```bash +git clone https://aur.archlinux.org/mpvcovergrabber-git.git +cd mpvcovergrabber-git +makepkg -si +``` + +This will install the following files: +- `/etc/mpv/scripts/notify.lua` +- `/etc/mpv/filters/patterns_artist.txt` +- `/etc/mpv/filters/patterns_common.txt` + +## Dependencies + +- [mpv](https://mpv.io) +- `curl` +- `coreutils` + +### Optional dependencies + +- [Ollama](https://ollama.com): for LLM-based metadata inference (e.g., gemma2) +- `ollama-cuda`: for GPU-accelerated inference + +## Configuration + +Edit the pattern files to customize metadata cleanup: +- `/etc/mpv/filters/patterns_artist.txt` +- `/etc/mpv/filters/patterns_common.txt` + +Store your API keys in: +- `~/.config/mpv/apikeys/lastfm` +- `~/.config/mpv/apikeys/tmdb` + +## Author + +Developed by [Teraflops](https://gitlab.com/teraflops) + +--- + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for more details. + + diff --git a/etc/mpv/filters/patterns_artist.txt b/etc/mpv/filters/patterns_artist.txt new file mode 100644 index 0000000..e739ba7 --- /dev/null +++ b/etc/mpv/filters/patterns_artist.txt @@ -0,0 +1,5 @@ +[Oo]fficial +vevo +topic +auto%-generated + diff --git a/etc/mpv/filters/patterns_common.txt b/etc/mpv/filters/patterns_common.txt new file mode 100644 index 0000000..8a5d274 --- /dev/null +++ b/etc/mpv/filters/patterns_common.txt @@ -0,0 +1,80 @@ +(?i)%s*cover$ +[wW][wW][wW]%.%a+%.com +^[ABCDEF]%d+%s+ +^%d+[%s%.%-_]* +%b() +%b[] +%b{} +[Uu]ltimate [Mm]ix +[Rr]emaster(ed)? +[Ll]yric [Vv]ideo +[Oo]fficial [Mm]usic [Vv]ideo +[Ff]ull [Aa]lbum +[Aa]udio [Oo]nly +[Hh][Dd] +[Mm][Vv] +[Uu][Hh][Dd] +4K +[xX]264 +[xX]265 +[Hh][Ee][Vv][Cc] +[Ww][Ee][Bb].?Rip +[Bb][Rr][Rr]ip +[Dd][Vv][Dd][Rr]ip +%.%w+$ +[–—−] +^%d+%.%s* +Official Audio +Official Video +Official Promo +OFFICIAL PROMO +IMAX +WebDL +60fps +dual +Promo +Version %d+ +Full Album +Lyrics +Video +MusicVideo +WEL +#%w+ +%- +\\ +%.mkv +%.mp4 +%.avi +%.www +%..-[%w%-]+%.com +(%d)%.(%d) +(%D)%.(%D) +DOT +[bB][dD][rR]?[iI]?[pP]? +2160p +2160 +1080p +720p +480p +360p +30fps +HDR +[Ww][Ee][Bb][%-%.]?[Dd][Ll] +[Ww][Ee][Bb][Rr][Ii][Pp] +HEVC +H264 +H265 +AC3 +remux +UHD +WeL +DTS +FLAC +multi +rip +reescalado +sub +: +wwwnewpct1 +1080wwwpctnewcom +M1080wwwnewpct1com diff --git a/etc/mpv/scripts/notify.lua b/etc/mpv/scripts/notify.lua new file mode 100644 index 0000000..f538f9f --- /dev/null +++ b/etc/mpv/scripts/notify.lua @@ -0,0 +1,280 @@ +-- ~/.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)