Refactor flake.nix and enhance project structure
- Updated `flake.nix` to define a new Python application `lidarr-mb-gap` for identifying missing albums on MusicBrainz. - Improved development shell environment by including a Python environment with necessary packages. - Added new source files: `__init__.py`, `html_report.py`, and `main.py` to implement core functionality and HTML report generation. - Introduced `pyproject.toml` for better package management and project metadata. - Enhanced user instructions in the shell hook for running the application.
This commit is contained in:
2
src/__init__.py
Normal file
2
src/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Lidarr to MusicBrainz Missing Albums Finder"""
|
||||
|
||||
360
src/html_report.py
Normal file
360
src/html_report.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""HTML report generation for missing albums"""
|
||||
|
||||
import logging
|
||||
from html import escape
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_html_report(albums_to_add: List[Dict], albums_to_update: List[Dict]):
|
||||
"""Generate an HTML report with clickable submission links"""
|
||||
html_content = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MusicBrainz Albums - Add & Update</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
h1 {{
|
||||
color: #333;
|
||||
border-bottom: 3px solid #4CAF50;
|
||||
padding-bottom: 10px;
|
||||
}}
|
||||
h2 {{
|
||||
color: #2196F3;
|
||||
margin-top: 30px;
|
||||
border-bottom: 2px solid #2196F3;
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
.album {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.album-title {{
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
color: #2196F3;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
.artist-name {{
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
.links {{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.link-button {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.3s;
|
||||
}}
|
||||
.link-button:hover {{
|
||||
background-color: #45a049;
|
||||
}}
|
||||
.link-button.atisket {{
|
||||
background-color: #2196F3;
|
||||
}}
|
||||
.link-button.atisket:hover {{
|
||||
background-color: #0b7dda;
|
||||
}}
|
||||
.link-button.harmony {{
|
||||
background-color: #FF9800;
|
||||
}}
|
||||
.link-button.harmony:hover {{
|
||||
background-color: #e68900;
|
||||
}}
|
||||
.deezer-link {{
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
.mb-link {{
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}}
|
||||
.issues {{
|
||||
color: #FF9800;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
font-style: italic;
|
||||
}}
|
||||
.summary {{
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.filter-buttons {{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.filter-button {{
|
||||
padding: 10px 20px;
|
||||
border: 2px solid #2196F3;
|
||||
background-color: white;
|
||||
color: #2196F3;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
transition: all 0.3s;
|
||||
}}
|
||||
.filter-button:hover {{
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}}
|
||||
.filter-button.active {{
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}}
|
||||
.album-section {{
|
||||
display: none;
|
||||
}}
|
||||
.album-section.visible {{
|
||||
display: block;
|
||||
}}
|
||||
.filter-container {{
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}}
|
||||
.filter-group {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}}
|
||||
.filter-group label {{
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
.artist-buttons {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}}
|
||||
.artist-button {{
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: white;
|
||||
color: #666;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.3s;
|
||||
}}
|
||||
.artist-button:hover {{
|
||||
background-color: #f0f0f0;
|
||||
border-color: #2196F3;
|
||||
}}
|
||||
.artist-button.active {{
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
border-color: #2196F3;
|
||||
}}
|
||||
.album[data-artist] {{
|
||||
display: block;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎵 MusicBrainz Albums - Add & Update</h1>
|
||||
<div class="summary">
|
||||
<strong>Albums to ADD: {add_count}</strong> | <strong>Albums to UPDATE: {update_count}</strong>
|
||||
</div>
|
||||
<div class="filter-container">
|
||||
<div class="filter-group">
|
||||
<label>Filter by Type:</label>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-button active" data-filter="all">Show All</button>
|
||||
<button class="filter-button" data-filter="add">To ADD ({add_count})</button>
|
||||
<button class="filter-button" data-filter="update">To UPDATE ({update_count})</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Filter by Artist:</label>
|
||||
<div class="artist-buttons">
|
||||
<button class="artist-button active" data-artist="all">All Artists</button>
|
||||
{artist_buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {{
|
||||
const typeButtons = document.querySelectorAll('.filter-button');
|
||||
const artistButtons = document.querySelectorAll('.artist-button');
|
||||
const addSection = document.getElementById('albums-to-add');
|
||||
const updateSection = document.getElementById('albums-to-update');
|
||||
|
||||
let currentTypeFilter = 'all';
|
||||
let currentArtistFilter = 'all';
|
||||
|
||||
function applyFilters() {{
|
||||
const albums = document.querySelectorAll('.album');
|
||||
|
||||
albums.forEach(album => {{
|
||||
const albumArtist = album.getAttribute('data-artist');
|
||||
const isInAddSection = addSection && addSection.contains(album);
|
||||
const isInUpdateSection = updateSection && updateSection.contains(album);
|
||||
|
||||
let showByType = false;
|
||||
if (currentTypeFilter === 'all') {{
|
||||
showByType = true;
|
||||
}} else if (currentTypeFilter === 'add' && isInAddSection) {{
|
||||
showByType = true;
|
||||
}} else if (currentTypeFilter === 'update' && isInUpdateSection) {{
|
||||
showByType = true;
|
||||
}}
|
||||
|
||||
const showByArtist = currentArtistFilter === 'all' || albumArtist === currentArtistFilter;
|
||||
|
||||
if (showByType && showByArtist) {{
|
||||
album.style.display = 'block';
|
||||
}} else {{
|
||||
album.style.display = 'none';
|
||||
}}
|
||||
}});
|
||||
|
||||
if (addSection) {{
|
||||
const hasVisibleAlbums = Array.from(addSection.querySelectorAll('.album'))
|
||||
.some(album => album.style.display !== 'none');
|
||||
addSection.style.display = hasVisibleAlbums ? 'block' : 'none';
|
||||
}}
|
||||
|
||||
if (updateSection) {{
|
||||
const hasVisibleAlbums = Array.from(updateSection.querySelectorAll('.album'))
|
||||
.some(album => album.style.display !== 'none');
|
||||
updateSection.style.display = hasVisibleAlbums ? 'block' : 'none';
|
||||
}}
|
||||
}}
|
||||
|
||||
typeButtons.forEach(button => {{
|
||||
button.addEventListener('click', function() {{
|
||||
typeButtons.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
currentTypeFilter = this.getAttribute('data-filter');
|
||||
applyFilters();
|
||||
}});
|
||||
}});
|
||||
|
||||
artistButtons.forEach(button => {{
|
||||
button.addEventListener('click', function() {{
|
||||
artistButtons.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
currentArtistFilter = this.getAttribute('data-artist');
|
||||
applyFilters();
|
||||
}});
|
||||
}});
|
||||
}});
|
||||
</script>
|
||||
"""
|
||||
|
||||
album_html = """
|
||||
<div class="album" data-artist="{artist_escaped}">
|
||||
<div class="album-title">{title}</div>
|
||||
<div class="artist-name">by {artist}</div>
|
||||
{mb_info}
|
||||
{issues_info}
|
||||
<div class="links">
|
||||
<a href="{atisket_link}" target="_blank" class="link-button atisket">Submit via a-tisket</a>
|
||||
<a href="{harmony_link}" target="_blank" class="link-button harmony">Submit via Harmony</a>
|
||||
</div>
|
||||
<div class="deezer-link">
|
||||
<a href="{deezer_url}" target="_blank">View on Deezer</a>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def format_album(album: Dict, is_update: bool = False) -> str:
|
||||
submission_links = album.get("submission_links", {})
|
||||
mb_info = ""
|
||||
issues_info = ""
|
||||
|
||||
if is_update:
|
||||
mb_url = album.get("mb_url", "")
|
||||
if mb_url:
|
||||
mb_info = f'<div class="mb-link"><a href="{mb_url}" target="_blank">View on MusicBrainz</a></div>'
|
||||
issues = album.get("album_issues", [])
|
||||
if issues:
|
||||
issues_info = f'<div class="issues">Issues: {", ".join(issues)}</div>'
|
||||
|
||||
title = album.get("title", "Unknown Title")
|
||||
artist = album.get("artist_name", "Unknown Artist")
|
||||
artist_escaped = escape(artist)
|
||||
atisket_link = submission_links.get("atisket_link", "#")
|
||||
harmony_link = submission_links.get("harmony_link", "#")
|
||||
deezer_url = submission_links.get("deezer_url", "#")
|
||||
|
||||
return album_html.format(
|
||||
title=escape(title),
|
||||
artist=artist,
|
||||
artist_escaped=artist_escaped,
|
||||
mb_info=mb_info,
|
||||
issues_info=issues_info,
|
||||
atisket_link=atisket_link,
|
||||
harmony_link=harmony_link,
|
||||
deezer_url=deezer_url,
|
||||
)
|
||||
|
||||
all_albums = albums_to_add + albums_to_update
|
||||
unique_artists = sorted(
|
||||
set(album.get("artist_name", "Unknown") for album in all_albums)
|
||||
)
|
||||
|
||||
artist_buttons_html = "".join(
|
||||
f'<button class="artist-button" data-artist="{escape(artist)}">{escape(artist)}</button>'
|
||||
for artist in unique_artists
|
||||
)
|
||||
|
||||
albums_html = ""
|
||||
if albums_to_add:
|
||||
albums_html += '<div id="albums-to-add" class="album-section visible">'
|
||||
albums_html += "<h2>📥 Albums to ADD (Not in MusicBrainz)</h2>"
|
||||
formatted_add = map(lambda album: format_album(album, False), albums_to_add)
|
||||
albums_html += "".join(formatted_add)
|
||||
albums_html += "</div>"
|
||||
|
||||
if albums_to_update:
|
||||
albums_html += '<div id="albums-to-update" class="album-section visible">'
|
||||
albums_html += "<h2>🔄 Albums to UPDATE (Need Linking/Updates)</h2>"
|
||||
formatted_update = map(
|
||||
lambda album: format_album(album, True), albums_to_update
|
||||
)
|
||||
albums_html += "".join(formatted_update)
|
||||
albums_html += "</div>"
|
||||
|
||||
add_count = len(albums_to_add)
|
||||
update_count = len(albums_to_update)
|
||||
html_header = html_content.format(
|
||||
add_count=add_count,
|
||||
update_count=update_count,
|
||||
artist_buttons=artist_buttons_html,
|
||||
)
|
||||
html_footer = "\n</body>\n</html>\n"
|
||||
html_content = html_header + albums_html + html_footer
|
||||
|
||||
with open("missing_albums.html", "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
logger.info("HTML report saved to missing_albums.html")
|
||||
358
src/main.py
Executable file
358
src/main.py
Executable file
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to identify missing albums on MusicBrainz from Deezer releases
|
||||
for artists monitored in Lidarr, and generate submission links.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from html_report import generate_html_report
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LidarrClient:
|
||||
"""Client for interacting with Lidarr API"""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.headers = {"X-Api-Key": api_key}
|
||||
|
||||
def get_artists(self) -> List[Dict]:
|
||||
"""Fetch all artists from Lidarr"""
|
||||
url = f"{self.base_url}/api/v1/artist"
|
||||
try:
|
||||
response = requests.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching artists from Lidarr: {e}")
|
||||
return []
|
||||
|
||||
def get_monitored_artists(
|
||||
self, monitor_types: Optional[List[str]] = None
|
||||
) -> List[Dict]:
|
||||
"""Get artists with monitorNewItems set to specified values"""
|
||||
if monitor_types is None:
|
||||
monitor_types = ["new", "all"]
|
||||
return list(
|
||||
filter(
|
||||
lambda artist: artist.get("monitorNewItems") in monitor_types,
|
||||
self.get_artists(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SamblClient:
|
||||
"""Client for interacting with SAMBL API to find missing albums"""
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None):
|
||||
self.base_url = (base_url or "https://sambl.lioncat6.com").rstrip("/")
|
||||
|
||||
def _search_deezer_artist(self, artist_name: str) -> Optional[str]:
|
||||
"""Search for an artist on Deezer and return their Deezer ID"""
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.deezer.com/search/artist",
|
||||
params={"q": artist_name, "limit": 1},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("data") and len(data["data"]) > 0:
|
||||
return str(data["data"][0]["id"])
|
||||
return None
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
def _extract_albums(self, data: Dict) -> List[Dict]:
|
||||
"""Extract albums list from SAMBL response"""
|
||||
if isinstance(data, dict):
|
||||
album_data = data.get("albumData")
|
||||
if isinstance(album_data, list):
|
||||
return album_data
|
||||
if isinstance(album_data, dict):
|
||||
return album_data.get("albums", album_data.get("data", []))
|
||||
if isinstance(data.get("albums"), list):
|
||||
return data.get("albums", [])
|
||||
if isinstance(data.get("data"), list):
|
||||
return data.get("data", [])
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
def _build_album_data(self, album: Dict, artist_name: str) -> Optional[Dict]:
|
||||
"""Build album data dictionary from SAMBL album response"""
|
||||
deezer_id = str(
|
||||
album.get("id") or album.get("deezer_id") or album.get("deezerId") or ""
|
||||
)
|
||||
if not deezer_id or deezer_id == "None":
|
||||
return None
|
||||
|
||||
return {
|
||||
"title": album.get("name") or album.get("title") or "Unknown",
|
||||
"deezer_url": f"https://www.deezer.com/album/{deezer_id}",
|
||||
"deezer_id": deezer_id,
|
||||
"release_date": album.get("releaseDate")
|
||||
or album.get("release_date")
|
||||
or album.get("release")
|
||||
or "",
|
||||
"artist_name": artist_name,
|
||||
"cover_url": album.get("imageUrl")
|
||||
or album.get("cover")
|
||||
or album.get("cover_medium")
|
||||
or album.get("coverUrl")
|
||||
or "",
|
||||
}
|
||||
|
||||
def _has_valid_deezer_id(self, album: Dict) -> bool:
|
||||
"""Check if album has a valid Deezer ID"""
|
||||
deezer_id = str(
|
||||
album.get("id") or album.get("deezer_id") or album.get("deezerId") or ""
|
||||
)
|
||||
return bool(deezer_id and deezer_id != "None")
|
||||
|
||||
def _get_album_status(self, album: Dict) -> str:
|
||||
"""Get album status from SAMBL response"""
|
||||
return str(album.get("albumStatus", "")).lower()
|
||||
|
||||
def _get_mbid(self, album: Dict) -> str:
|
||||
"""Extract MusicBrainz ID from album"""
|
||||
return (
|
||||
album.get("mbid")
|
||||
or album.get("musicbrainz_id")
|
||||
or album.get("musicbrainzId")
|
||||
or ""
|
||||
)
|
||||
|
||||
def _is_album_to_add(self, album: Dict) -> bool:
|
||||
"""Check if album needs to be added to MusicBrainz"""
|
||||
status = self._get_album_status(album)
|
||||
mbid = self._get_mbid(album)
|
||||
return status == "red" or not mbid
|
||||
|
||||
def _is_album_to_update(self, album: Dict) -> bool:
|
||||
"""Check if album needs to be updated in MusicBrainz"""
|
||||
return self._get_album_status(album) == "orange"
|
||||
|
||||
def _enrich_update_album(self, album_data: Dict, album: Dict) -> Dict:
|
||||
"""Enrich album data with MusicBrainz information for updates"""
|
||||
musicbrainz_id = self._get_mbid(album)
|
||||
album_data["mbid"] = musicbrainz_id
|
||||
album_data["mb_url"] = album.get(
|
||||
"albumMBUrl", f"https://musicbrainz.org/release/{musicbrainz_id}"
|
||||
)
|
||||
album_data["album_issues"] = album.get("albumIssues", [])
|
||||
return album_data
|
||||
|
||||
def find_missing_albums(
|
||||
self, artist_mbid: str, artist_name: str
|
||||
) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Find albums missing on MusicBrainz from Deezer releases for an artist"""
|
||||
deezer_artist_id = self._search_deezer_artist(artist_name)
|
||||
if not deezer_artist_id:
|
||||
return [], []
|
||||
|
||||
try:
|
||||
params = {
|
||||
"provider_id": deezer_artist_id,
|
||||
"provider": "deezer",
|
||||
"mbid": artist_mbid,
|
||||
"full": "true",
|
||||
}
|
||||
response = requests.get(
|
||||
f"{self.base_url}/api/compareArtistAlbums", params=params, timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
albums = self._extract_albums(response.json())
|
||||
|
||||
valid_albums = list(filter(self._has_valid_deezer_id, albums))
|
||||
albums_to_add_raw = list(filter(self._is_album_to_add, valid_albums))
|
||||
albums_to_update_raw = list(filter(self._is_album_to_update, valid_albums))
|
||||
|
||||
build_album = lambda album: self._build_album_data(album, artist_name)
|
||||
albums_to_add = list(map(build_album, albums_to_add_raw))
|
||||
|
||||
enrich_album = lambda album: self._enrich_update_album(
|
||||
self._build_album_data(album, artist_name), album
|
||||
)
|
||||
albums_to_update = list(map(enrich_album, albums_to_update_raw))
|
||||
|
||||
return albums_to_add, albums_to_update
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return [], []
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return [], []
|
||||
|
||||
|
||||
class SubmissionLinkGenerator:
|
||||
"""Generate submission links for MusicBrainz using a-tisket and Harmony"""
|
||||
|
||||
@staticmethod
|
||||
def generate_atisket_link(deezer_url: str) -> str:
|
||||
"""Generate an a-tisket submission link from a Deezer URL"""
|
||||
return f"https://atisket.pulsewidth.org.uk/?url={quote(deezer_url, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def generate_harmony_link(deezer_url: str) -> str:
|
||||
"""Generate a Harmony submission link from a Deezer URL"""
|
||||
return f"https://harmony.pulsewidth.org.uk/release?url={quote(deezer_url, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def generate_links(deezer_url: str) -> Dict[str, str]:
|
||||
"""Generate both a-tisket and Harmony links"""
|
||||
return {
|
||||
"deezer_url": deezer_url,
|
||||
"atisket_link": SubmissionLinkGenerator.generate_atisket_link(deezer_url),
|
||||
"harmony_link": SubmissionLinkGenerator.generate_harmony_link(deezer_url),
|
||||
}
|
||||
|
||||
|
||||
def _process_albums(albums: List[Dict], action: str) -> List[Dict]:
|
||||
"""Process albums and generate submission links"""
|
||||
return list(
|
||||
map(
|
||||
lambda album: {
|
||||
**album,
|
||||
"submission_links": SubmissionLinkGenerator.generate_links(
|
||||
album["deezer_url"]
|
||||
),
|
||||
"action": action,
|
||||
},
|
||||
filter(lambda album: album.get("deezer_url"), albums),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _format_album_output(album: Dict) -> str:
|
||||
"""Format album information for console output"""
|
||||
lines = [f" 📀 {album.get('title', 'Unknown Title')}"]
|
||||
lines.append(f" Deezer: {album.get('deezer_url')}")
|
||||
if album.get("mb_url"):
|
||||
lines.append(f" MusicBrainz: {album['mb_url']}")
|
||||
if album.get("album_issues"):
|
||||
lines.append(f" Issues: {', '.join(album['album_issues'])}")
|
||||
links = album.get("submission_links", {})
|
||||
lines.append(f" a-tisket: {links.get('atisket_link')}")
|
||||
lines.append(f" Harmony: {links.get('harmony_link')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main execution function"""
|
||||
LIDARR_URL = os.getenv("LIDARR_URL")
|
||||
LIDARR_API_KEY = os.getenv("LIDARR_API_KEY")
|
||||
SAMBL_URL = os.getenv("SAMBL_URL") or None
|
||||
MAX_ARTISTS = int(os.getenv("MAX_ARTISTS", "5"))
|
||||
|
||||
if not LIDARR_URL:
|
||||
logger.error("LIDARR_URL not set")
|
||||
sys.exit(1)
|
||||
|
||||
if not LIDARR_API_KEY:
|
||||
logger.error("LIDARR_API_KEY not set")
|
||||
sys.exit(1)
|
||||
|
||||
lidarr = LidarrClient(LIDARR_URL, LIDARR_API_KEY)
|
||||
sambl = SamblClient(SAMBL_URL)
|
||||
|
||||
logger.info("Fetching monitored artists from Lidarr...")
|
||||
artists = lidarr.get_monitored_artists(["new", "all"])
|
||||
|
||||
if not artists:
|
||||
logger.warning("No artists found with monitorNewItems set to 'new' or 'all'")
|
||||
return
|
||||
|
||||
total_artists = len(artists)
|
||||
if MAX_ARTISTS > 0 and total_artists > MAX_ARTISTS:
|
||||
logger.info(
|
||||
f"Found {total_artists} monitored artists (limiting to {MAX_ARTISTS} for testing)"
|
||||
)
|
||||
artists = artists[:MAX_ARTISTS]
|
||||
else:
|
||||
logger.info(f"Found {total_artists} monitored artists")
|
||||
logger.info("=" * 80)
|
||||
|
||||
all_albums_to_add = []
|
||||
all_albums_to_update = []
|
||||
|
||||
for artist in artists:
|
||||
artist_name = artist.get("artistName", "Unknown")
|
||||
artist_mbid = artist.get("foreignArtistId") or artist.get("mbid")
|
||||
|
||||
if not artist_mbid:
|
||||
logger.warning(f"Skipping {artist_name} - no MusicBrainz ID found")
|
||||
continue
|
||||
|
||||
logger.info(f"Artist: {artist_name}")
|
||||
logger.info(f"MusicBrainz ID: {artist_mbid}")
|
||||
|
||||
albums_to_add, albums_to_update = sambl.find_missing_albums(
|
||||
artist_mbid, artist_name
|
||||
)
|
||||
|
||||
if albums_to_add:
|
||||
logger.info(f"Albums to ADD ({len(albums_to_add)}):")
|
||||
processed = _process_albums(albums_to_add, "add")
|
||||
all_albums_to_add.extend(processed)
|
||||
for album_output in map(_format_album_output, processed):
|
||||
logger.info(album_output)
|
||||
|
||||
if albums_to_update:
|
||||
logger.info(f"Albums to UPDATE ({len(albums_to_update)}):")
|
||||
processed = _process_albums(albums_to_update, "update")
|
||||
all_albums_to_update.extend(processed)
|
||||
for album_output in map(_format_album_output, processed):
|
||||
logger.info(album_output)
|
||||
|
||||
if not albums_to_add and not albums_to_update:
|
||||
logger.info("All albums are properly linked!")
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("Summary:")
|
||||
artists_info = f"Artists processed: {len(artists)}"
|
||||
if MAX_ARTISTS > 0 and total_artists > MAX_ARTISTS:
|
||||
artists_info += f" (of {total_artists} total)"
|
||||
logger.info(artists_info)
|
||||
logger.info(f"Albums to ADD: {len(all_albums_to_add)}")
|
||||
logger.info(f"Albums to UPDATE: {len(all_albums_to_update)}")
|
||||
|
||||
all_albums = all_albums_to_add + all_albums_to_update
|
||||
if not all_albums:
|
||||
logger.info("All albums are already on MusicBrainz!")
|
||||
return
|
||||
|
||||
output_data = {
|
||||
"albums_to_add": all_albums_to_add,
|
||||
"albums_to_update": all_albums_to_update,
|
||||
"summary": {
|
||||
"total_to_add": len(all_albums_to_add),
|
||||
"total_to_update": len(all_albums_to_update),
|
||||
"total": len(all_albums),
|
||||
},
|
||||
}
|
||||
with open("missing_albums.json", "w", encoding="utf-8") as f:
|
||||
json.dump(output_data, f, indent=2, ensure_ascii=False)
|
||||
logger.info("Results saved to missing_albums.json")
|
||||
|
||||
generate_html_report(all_albums_to_add, all_albums_to_update)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
src/pyproject.toml
Normal file
20
src/pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["main", "html_report"]
|
||||
|
||||
[project]
|
||||
name = "lidarr-mb-gap"
|
||||
version = "1.0.0"
|
||||
description = "Lidarr to MusicBrainz Missing Albums Finder"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"requests",
|
||||
"python-dotenv",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
lidarr-mb-gap = "main:main"
|
||||
|
||||
Reference in New Issue
Block a user