initial commit

This commit is contained in:
teraflops 2025-05-28 18:42:16 +02:00
commit bbf4c14926
Signed by: teraflops
GPG Key ID: 2B77D97AF6F8968C
4 changed files with 320 additions and 0 deletions

14
LICENSE Normal file
View File

@ -0,0 +1,14 @@
BSD Zero Clause License
Copyright (c) 2024 teraflops
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

69
README.md Normal file
View File

@ -0,0 +1,69 @@
## MPRIS Last.fm Cover Downloader
# Real-time Album Cover Management
MPRIS Last.fm Cover Downloader seamlessly integrates with your music playback to ensure your album covers are always up-to-date without manual intervention. By leveraging the MPRIS interface and Last.fm API, this tool automatically downloads missing album covers, eliminating the need for constant polling and reducing CPU usage.
Automatic Cover Downloads for Every Track
When a new track starts playing, the application checks for the presence of the album cover in your local music directory. If the cover is missing, it fetches the highest available resolution image from Last.fm and saves it automatically.
The downloader intelligently selects the largest available cover image from Last.fm, ensuring high-quality visuals for your music library. It prioritizes sizes in the following order: mega, extralarge, large, medium, small.
Efficient Resource Usage
Designed to minimize CPU usage, the application avoids unnecessary polling by reacting to real-time events from the MPRIS interface. This ensures your system remains responsive and efficient while managing album covers.
# Requirements
Operating System: Linux
Python Dependencies:
requests
dbus-next
pathlib
logging
System Tools:
mpdris2-rs
MPD (Music Player Daemon) or another MPRIS-compatible player
Last.fm Account to obtain an API key.
# Installation
git clone the repo.
just copy the files to the same location as they are in the repository
# Configuration
```bash
systemctl --user daemon-reload
systemctl --user enable mpdcovergrabber.service
systemctl --user start mpdcovergrabber.service
```
Put your lastfm apikey here $HOME/apikeys/lastfm
# Contributing
Contributions are welcome! To improve this application, please follow these steps:
Fork the Repository.
Create a New Branch for Your Feature or Fix:
git checkout -b feature/new-feature
Make Your Changes and Commit Them:
git commit -m "Add new feature"
Push Your Changes to the Fork:
git push origin feature/new-feature
Open a Merge Request on GitLab.
License
This project is licensed under the BSD License. See the LICENSE file for details.
Contact
If you have any questions or suggestions, feel free to open an issue in the repository or contact me directly via me@priet.us.
Thank you for using MPRIS Last.fm Cover Downloader! We hope this tool makes managing your music album covers easier and more efficient.

223
usr/bin/mpdris2_cover.py Executable file
View File

@ -0,0 +1,223 @@
import os
import requests
import urllib.parse
import asyncio
from dbus_next.aio import MessageBus
from dbus_next import Variant
from pathlib import Path
import logging
logging.basicConfig(level=logging.INFO) # Set logging level to INFO
logger = logging.getLogger(__name__)
# Get the Last.fm API key path from an environment variable
API_KEY_FILE = os.environ.get(
'LASTFM_API_KEY_PATH', os.path.expandvars('$HOME/apikeys/lastfm'))
def get_lastfm_api_key():
"""Read the Last.fm API key from a file."""
try:
with open(API_KEY_FILE, 'r') as file:
api_key = file.readline().strip()
logger.info(f"Successfully read Last.fm API key from {
API_KEY_FILE}")
return api_key
except FileNotFoundError:
logger.info(f"Error: API key file '{
API_KEY_FILE}' not found. Please check the path.")
return None
except Exception as e:
logger.info(f"Error reading API key: {e}")
return None
# Get the Last.fm API key
LASTFM_API_KEY = get_lastfm_api_key()
if not LASTFM_API_KEY:
raise RuntimeError(
"Last.fm API key could not be loaded. Please check the API key file path.")
else:
print("API key loaded successfully. Continuing with the script.")
# Get the base path of the music library from an environment variable
MUSIC_BASE_PATH = os.environ.get(
'MUSIC_LIBRARY_PATH', os.path.expandvars('$HOME/media/all'))
def cover_exists(album_dir):
"""Check if 'cover.jpg' or 'Cover.jpg' exists in the album directory."""
cover_path_lower = album_dir / "cover.jpg"
cover_path_upper = album_dir / "Cover.jpg"
return cover_path_lower.exists() or cover_path_upper.exists()
def download_image(cover_url, output_path):
"""Download the cover image from a URL and save it to the specified path."""
try:
response = requests.get(cover_url, stream=True)
if response.status_code == 200:
with open(output_path, 'wb') as f:
for chunk in response.iter_content(1024):
f.write(chunk)
logger.info(f"Image downloaded to {output_path}")
return output_path
else:
logger.info(f"Failed to download image. HTTP Status: {
response.status_code}")
except Exception as e:
logger.info(f"Error downloading image: {e}")
return None
def get_largest_cover_image(data):
"""Select the largest available cover image from the Last.fm response."""
if 'album' in data and 'image' in data['album']:
# Define the priority order of sizes
size_priority = ["mega", "extralarge", "large", "medium", "small"]
for size in size_priority:
for img in data['album']['image']:
if img["size"] == size and img["#text"]:
logger.info(f"Using {size} cover: {img['#text']}")
return img["#text"]
return None
def download_cover_from_lastfm(artist, album, album_dir):
"""Download the album cover from Last.fm and save it in the album directory."""
try:
logger.info(f"Fetching cover for '{album}' by {
artist} from Last.fm...")
# Last.fm API URL
url = f"http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={LASTFM_API_KEY}&artist={
urllib.parse.quote(artist)}&album={urllib.parse.quote(album)}&format=json"
response = requests.get(url)
if response.status_code == 200:
data = response.json()
cover_url = get_largest_cover_image(data)
if cover_url:
# Ensure the directory exists
album_dir.mkdir(parents=True, exist_ok=True)
local_cover_path = album_dir / "cover.jpg" # Save as 'cover.jpg'
download_image(cover_url, local_cover_path)
logger.info(f"Downloaded cover to {local_cover_path}")
return local_cover_path
logger.info(f"No cover found for '{album}' by {artist} on Last.fm.")
except Exception as e:
logger.info(f"Error fetching cover from Last.fm: {e}")
return None
def get_local_album_dir(xesam_url):
"""Extract the album directory from the `xesam:url` path."""
if not xesam_url:
logger.info(
f"Warning: xesam:url is empty or missing. Skipping cover detection.")
return None
try:
logger.info(f"Raw xesam:url value: {xesam_url}")
# Convert the xesam URL to a local file path
local_path = urllib.parse.unquote(xesam_url).replace("file://", "")
logger.info(f"Converted local path: {local_path}")
# Remove '/trackXXXX' if it's a CUE file path
if "/track" in local_path and ".cue" in local_path:
local_path = local_path.split("/track")[0]
logger.info(f"Stripped CUE path: {local_path}")
# If the path is not absolute, add the music base directory
track_path = Path(local_path)
if not track_path.is_absolute():
track_path = Path(MUSIC_BASE_PATH) / track_path
logger.info(f"Updated to absolute path: {track_path}")
# Check if the resulting path exists
if not track_path.exists():
logger.info(f"Warning: The path '{track_path}' does not exist.")
return None
# Handle CUE files: Move to the actual album directory
if track_path.suffix == ".cue":
return track_path.parent
# Standard path handling
if track_path.is_file():
return track_path.parent
else:
logger.info(f"Path is not a valid file: {track_path}")
except Exception as e:
logger.info(f"Error extracting local album directory: {e}")
return None
class MprisCoverHandler:
def __init__(self):
self.bus = None
async def on_properties_changed(self, interface, changed, invalidated):
"""Handler for MPRIS property changes."""
if 'Metadata' in changed:
metadata = changed['Metadata'].value
# Extract metadata properties
track_title = metadata.get(
'xesam:title', Variant('s', 'Unknown')).value
artist = metadata.get('xesam:artist', Variant('as', [])).value
album = metadata.get('xesam:album', Variant('s', 'Unknown')).value
xesam_url = metadata.get('xesam:url', Variant('s', '')).value
if not isinstance(artist, list):
artist = [str(artist)]
artist = [str(a) for a in artist]
if not artist or album == 'Unknown' or not xesam_url:
logger.info(f"Skipping track '{
track_title}' due to missing metadata.")
return
logger.info(f"Now playing: {track_title} by {
', '.join(artist)} from the album '{album}'")
# Determine the local album directory based on xesam:url
album_dir = get_local_album_dir(xesam_url)
if not album_dir:
logger.info(f"Could not determine album directory for '{
track_title}'. xesam:url: {xesam_url}")
return
# Check if a cover already exists
if cover_exists(album_dir):
logger.info(f"Cover already exists in '{
album_dir}', doing nothing.")
else:
logger.info(f"No cover found in '{
album_dir}', attempting to download.")
download_cover_from_lastfm(artist[0], album, album_dir)
# Delay and execute the command to refresh Waybar
await asyncio.sleep(0.5)
os.system("/usr/bin/pkill -RTMIN+23 waybar")
async def main(self):
"""Main function to connect to D-Bus and listen for changes."""
print("Starting D-Bus listener...")
self.bus = await MessageBus().connect()
introspectable = await self.bus.introspect("org.mpris.MediaPlayer2.mpd", "/org/mpris/MediaPlayer2")
obj = self.bus.get_proxy_object(
"org.mpris.MediaPlayer2.mpd", "/org/mpris/MediaPlayer2", introspectable)
properties = obj.get_interface("org.freedesktop.DBus.Properties")
# Run the command once when the listener starts
# Small delay to ensure Waybar is ready
await asyncio.sleep(0.5)
os.system("/usr/bin/pkill -RTMIN+23 waybar")
properties.on_properties_changed(self.on_properties_changed)
print("D-Bus listener started successfully.")
await asyncio.Future() # Run indefinitely
# Run the main loop to monitor MPRIS changes
fetcher = MprisCoverHandler()
asyncio.run(fetcher.main())

View File

@ -0,0 +1,14 @@
[Unit]
Description=MPD MPRIS Album Cover Downloader
After=mpd.service
[Service]
ExecStart=/usr/bin/python3 /usr/bin/mpdris2_cover.py
Restart=on-failure
#User=teraflops
#Environment=DISPLAY=:0
#Environment=XAUTHORITY=/home/teraflops/.Xauthority
[Install]
WantedBy=default.target