initial commit
This commit is contained in:
commit
b25ebf7ca2
22
LICENSE
Normal file
22
LICENSE
Normal file
@ -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.
|
||||
|
65
README.md
Normal file
65
README.md
Normal file
@ -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.
|
||||
|
||||
|
5
etc/mpv/filters/patterns_artist.txt
Normal file
5
etc/mpv/filters/patterns_artist.txt
Normal file
@ -0,0 +1,5 @@
|
||||
[Oo]fficial
|
||||
vevo
|
||||
topic
|
||||
auto%-generated
|
||||
|
80
etc/mpv/filters/patterns_common.txt
Normal file
80
etc/mpv/filters/patterns_common.txt
Normal file
@ -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
|
280
etc/mpv/scripts/notify.lua
Normal file
280
etc/mpv/scripts/notify.lua
Normal file
@ -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)
|
Loading…
x
Reference in New Issue
Block a user