feat: Requests / Add to Library Rework - Unmonitored album default + … (#25)

* feat: Requests / Add to Library Rework - Unmonitored album default + Resilience

* checking for source + refresh album logic

* artist monitoring + auto downloading + various request fixes

* synchronous album requests

* format
This commit is contained in:
Harvey
2026-04-06 23:08:58 +01:00
committed by GitHub
parent a3e0b2f65a
commit 343bafd7f4
44 changed files with 2360 additions and 271 deletions
+265 -90
View File
@@ -1,4 +1,5 @@
import asyncio
import collections
import logging
import time
from typing import Any, Optional
@@ -17,6 +18,31 @@ logger = logging.getLogger(__name__)
_album_details_deduplicator = RequestDeduplicator()
_MAX_ARTIST_LOCKS = 64
_artist_locks: collections.OrderedDict[str, asyncio.Lock] = collections.OrderedDict()
def _get_artist_lock(artist_mbid: str) -> asyncio.Lock:
"""Get or create a per-artist lock. Evicts only unlocked entries when over limit."""
if artist_mbid in _artist_locks:
_artist_locks.move_to_end(artist_mbid)
return _artist_locks[artist_mbid]
lock = asyncio.Lock()
_artist_locks[artist_mbid] = lock
while len(_artist_locks) > _MAX_ARTIST_LOCKS:
# Find the oldest unlocked entry to evict
evicted = False
for key in list(_artist_locks.keys()):
if key == artist_mbid:
continue
if not _artist_locks[key].locked():
del _artist_locks[key]
evicted = True
break
if not evicted:
break
return lock
def _safe_int(value: Any, fallback: int = 0) -> int:
"""Coerce a value to int, returning fallback for non-numeric inputs."""
@@ -254,6 +280,19 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
logger.warning(f"Error getting album by foreign ID {album_mbid}: {e}")
return None
_ALBUM_MUTABLE_FIELDS = frozenset({"monitored"})
async def _update_album(self, album_id: int, updates: dict[str, Any]) -> dict[str, Any]:
"""Update album via PUT /album/{id} - synchronous 200 OK, returns updated object.
Callers must hold the per-artist lock to avoid lost-update races.
Only fields in _ALBUM_MUTABLE_FIELDS are applied unknown keys are silently dropped.
"""
safe_updates = {k: v for k, v in updates.items() if k in self._ALBUM_MUTABLE_FIELDS}
album = await self._get(f"/api/v1/album/{album_id}")
album.update(safe_updates)
return await self._put(f"/api/v1/album/{album_id}", album)
async def delete_album(self, album_id: int, delete_files: bool = False) -> bool:
try:
params = {"deleteFiles": str(delete_files).lower(), "addImportListExclusion": "false"}
@@ -266,6 +305,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
raise
async def add_album(self, musicbrainz_id: str, artist_repo) -> dict:
t0 = time.monotonic()
if not musicbrainz_id or not isinstance(musicbrainz_id, str):
raise ExternalServiceError("Invalid MBID provided")
@@ -289,104 +329,211 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
if not artist_mbid:
raise ExternalServiceError("Album lookup did not include artist MBID")
artist = await artist_repo._ensure_artist_exists(artist_mbid, artist_name)
# Serialize per-artist to prevent duplicate artist creation from concurrent requests
lock = _get_artist_lock(artist_mbid)
async with lock:
return await self._add_album_locked(
musicbrainz_id, artist_repo, t0,
candidate, album_title, album_type, secondary_types,
artist_mbid, artist_name,
)
async def _add_album_locked(
self,
musicbrainz_id: str,
artist_repo,
t0: float,
candidate: dict,
album_title: str,
album_type: str,
secondary_types: list,
artist_mbid: str,
artist_name: str | None,
) -> dict:
# Capture which albums are already monitored so we can revert any Lidarr auto-monitors after the add
pre_add_monitored_ids: set[int] = set()
try:
existing_items = await self._get("/api/v1/artist", params={"mbId": artist_mbid})
if existing_items:
existing_artist_id = existing_items[0].get("id")
if existing_artist_id:
albums_before = await self._get(
"/api/v1/album", params={"artistId": existing_artist_id}
)
if isinstance(albums_before, list):
pre_add_monitored_ids = {
a["id"] for a in albums_before if a.get("monitored")
}
except ExternalServiceError:
pass
t_artist = time.monotonic()
artist, artist_created = await artist_repo._ensure_artist_exists(artist_mbid, artist_name)
artist_id = artist["id"]
artist_ensure_ms = int((time.monotonic() - t_artist) * 1000)
album_obj = await self._get_album_by_foreign_id(musicbrainz_id)
action = "exists"
if not album_obj:
async def album_is_indexed():
a = await self._get_album_by_foreign_id(musicbrainz_id)
return a and a.get("id")
if album_obj:
album_id = album_obj["id"]
has_files = (album_obj.get("statistics") or {}).get("trackFileCount", 0) > 0
is_monitored = album_obj.get("monitored", False)
await self._wait_for_artist_commands_to_complete(artist_id, timeout=600.0)
album_obj = await self._wait_for(album_is_indexed, timeout=60.0, poll=5.0)
if not album_obj:
profile_id = artist.get("qualityProfileId")
if profile_id is None:
try:
qps = await self._get("/api/v1/qualityprofile")
if not qps:
raise ExternalServiceError("No quality profiles in Lidarr")
profile_id = qps[0]["id"]
except Exception: # noqa: BLE001
profile_id = self._settings.quality_profile_id
payload = {
"title": album_title,
"artistId": artist_id,
"artist": artist,
"foreignAlbumId": musicbrainz_id,
"monitored": True,
"anyReleaseOk": True,
"profileId": profile_id,
"images": [],
"addOptions": {"addType": "automatic", "searchForNewAlbum": True},
if has_files and is_monitored:
total_ms = int((time.monotonic() - t0) * 1000)
logger.info(
"add_album timing: album=%s artist_ensure=%dms total=%dms (already downloaded)",
musicbrainz_id[:8], artist_ensure_ms, total_ms,
)
await self._invalidate_album_list_caches()
return {
"message": f"Album already downloaded: {album_title}",
"payload": album_obj,
}
if not is_monitored:
album_obj = await self._update_album(album_id, {"monitored": True})
try:
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
except ExternalServiceError as exc:
logger.warning("Failed to queue AlbumSearch for %s: %s", musicbrainz_id, exc)
await self._unmonitor_auto_monitored_albums(
artist_id, musicbrainz_id, album_id, pre_add_monitored_ids
)
await self._invalidate_album_list_caches()
await self._cache.clear_prefix(f"{LIDARR_PREFIX}artists:mbids")
total_ms = int((time.monotonic() - t0) * 1000)
logger.info(
"add_album timing: album=%s artist_ensure=%dms total=%dms (existing album, monitor+search)",
musicbrainz_id[:8], artist_ensure_ms, total_ms,
)
return {
"message": f"Album monitored & search triggered: {album_title}",
"monitored": True,
"payload": album_obj,
}
# Album doesn't exist yet — wait for indexing after artist add/refresh
if artist_created:
await self._wait_for_artist_commands_to_complete(artist_id, timeout=120.0)
async def album_is_indexed():
a = await self._get_album_by_foreign_id(musicbrainz_id)
return a and a.get("id")
# Only wait for auto-indexing if we just created/refreshed the artist;
# for existing artists nothing triggered new indexing, so skip the long wait.
if artist_created:
album_obj = await self._wait_for(album_is_indexed, timeout=60.0, poll=5.0)
else:
album_obj = await album_is_indexed()
if not album_obj:
# Album not auto-indexed; POST to add it directly
profile_id = artist.get("qualityProfileId")
if profile_id is None:
try:
album_obj = await self._post("/api/v1/album", payload)
action = "added"
album_obj = await self._wait_for(album_is_indexed, timeout=120.0, poll=2.0)
except Exception as e:
err_str = str(e)
if "POST failed" in err_str or "405" in err_str:
logger.debug("Raw Lidarr rejection for %s: %s", album_title, err_str)
raise ExternalServiceError(
f"Cannot add this {album_type}. "
f"Lidarr rejected adding '{album_title}'. This is likely because your Lidarr "
f"Metadata Profile is configured to exclude {album_type}s{' (' + ', '.join(secondary_types) + ')' if secondary_types else ''}. "
f"To fix this: Go to Lidarr -> Settings -> Profiles -> Metadata Profiles, "
f"and enable '{album_type}' in your active profile."
)
else:
logger.debug("Unexpected error adding '%s': %s", album_title, err_str)
raise
qps = await self._get("/api/v1/qualityprofile")
if not qps:
raise ExternalServiceError("No quality profiles in Lidarr")
profile_id = qps[0]["id"]
except Exception: # noqa: BLE001
profile_id = self._settings.quality_profile_id
payload = {
"title": album_title,
"artistId": artist_id,
"artist": {
"id": artist["id"],
"artistName": artist.get("artistName"),
"foreignArtistId": artist.get("foreignArtistId"),
"qualityProfileId": artist.get("qualityProfileId"),
"metadataProfileId": artist.get("metadataProfileId"),
"rootFolderPath": artist.get("rootFolderPath"),
},
"foreignAlbumId": musicbrainz_id,
"monitored": True,
"anyReleaseOk": True,
"profileId": profile_id,
"images": [],
"addOptions": {"addType": "automatic", "searchForNewAlbum": True},
}
try:
album_obj = await self._post("/api/v1/album", payload)
album_obj = await self._wait_for(album_is_indexed, timeout=120.0, poll=2.0)
except ExternalServiceError as e:
err_str = str(e).lower()
if "already exists" in err_str:
logger.info("Album %s already exists per Lidarr, fetching", musicbrainz_id)
album_obj = await self._get_album_by_foreign_id(musicbrainz_id)
if album_obj:
if not album_obj.get("monitored"):
album_obj = await self._update_album(album_obj["id"], {"monitored": True})
try:
await self._post_command(
{"name": "AlbumSearch", "albumIds": [album_obj["id"]]}
)
except ExternalServiceError:
pass
elif "post failed" in err_str or "405" in err_str or "metadata" in err_str:
raise ExternalServiceError(
f"Lidarr rejected '{album_title}' ({album_type}"
f"{'' + ', '.join(secondary_types) if secondary_types else ''}). "
f"Your Metadata Profile probably excludes {album_type}s. "
f"Go to Lidarr > Settings > Profiles > Metadata Profiles and enable '{album_type}'."
)
else:
logger.error("Unexpected error adding '%s': %s", album_title, e)
raise
if not album_obj or "id" not in album_obj:
raise ExternalServiceError(
f"Cannot add this {album_type}. "
f"'{album_title}' could not be found in Lidarr after the artist refresh. This usually means "
f"your Lidarr Metadata Profile is configured to exclude {album_type}s. "
f"To fix this: Go to Lidarr -> Settings -> Profiles -> Metadata Profiles, "
f"enable '{album_type}', then refresh the artist in Lidarr."
f"'{album_title}' wasn't found in Lidarr after refreshing the artist. "
f"Your Metadata Profile may exclude {album_type}s. "
f"Go to Lidarr > Settings > Profiles > Metadata Profiles, enable '{album_type}', then refresh the artist."
)
album_id = album_obj["id"]
await self._wait_for_artist_commands_to_complete(artist_id, timeout=600.0)
await self._monitor_artist_and_album(artist_id, album_id, musicbrainz_id, album_title)
# Only monitor the specific album; only set artist monitored if newly created
await self._monitor_artist_and_album(
artist_id, album_id, musicbrainz_id, album_title,
set_artist_monitored=artist_created,
)
try:
await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]})
except ExternalServiceError as exc:
logger.warning("Failed to queue Lidarr AlbumSearch for %s: %s", musicbrainz_id, exc)
logger.warning("Failed to queue AlbumSearch for %s: %s", musicbrainz_id, exc)
# Unmonitor albums that Lidarr auto-monitored during the add
await self._unmonitor_auto_monitored_albums(
artist_id, musicbrainz_id, album_id, pre_add_monitored_ids
)
final_album = await self._get_album_by_foreign_id(musicbrainz_id)
if final_album and not final_album.get("monitored"):
try:
await self._put("/api/v1/album/monitor", {
"albumIds": [album_id],
"monitored": True
})
await asyncio.sleep(2.0)
final_album = await self._get_album_by_foreign_id(musicbrainz_id)
except ExternalServiceError as exc:
logger.warning("Failed to update Lidarr album monitor state for %s: %s", musicbrainz_id, exc)
await self._invalidate_album_list_caches()
await self._cache.clear_prefix(f"{LIDARR_PREFIX}artists:mbids")
msg = "Album added & monitored" if action == "added" else "Album exists; monitored ensured"
total_ms = int((time.monotonic() - t0) * 1000)
logger.info(
"add_album timing: album=%s artist_ensure=%dms total=%dms (new album, created=%s)",
musicbrainz_id[:8], artist_ensure_ms, total_ms, artist_created,
)
return {
"message": f"{msg}: {album_title}",
"payload": final_album or album_obj
"message": f"Album added & monitored: {album_title}",
"monitored": True,
"payload": final_album or album_obj,
}
async def _wait_for_artist_commands_to_complete(self, artist_id: int, timeout: float = 600.0) -> None:
async def _wait_for_artist_commands_to_complete(self, artist_id: int, timeout: float = 120.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
@@ -418,7 +565,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
await asyncio.sleep(5.0)
await asyncio.sleep(5.0)
await asyncio.sleep(1.0)
async def _monitor_artist_and_album(
self,
@@ -426,35 +573,63 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
album_id: int,
album_mbid: str,
album_title: str,
max_attempts: int = 3
max_attempts: int = 2,
set_artist_monitored: bool = False,
) -> None:
for attempt in range(max_attempts):
try:
await self._put(
"/api/v1/artist/editor",
{"artistIds": [artist_id], "monitored": True, "monitorNewItems": "none"},
)
if set_artist_monitored and attempt == 0:
await self._put(
"/api/v1/artist/editor",
{"artistIds": [artist_id], "monitored": True, "monitorNewItems": "none"},
)
await asyncio.sleep(5.0 + (attempt * 3.0))
await self._put("/api/v1/album/monitor", {"albumIds": [album_id], "monitored": True})
async def both_monitored():
album = await self._get_album_by_foreign_id(album_mbid)
artist_data = await self._get(f"/api/v1/artist/{artist_id}")
return (album and album.get("monitored")) and (artist_data and artist_data.get("monitored"))
timeout = 20.0 + (attempt * 10.0)
if await self._wait_for(both_monitored, timeout=timeout, poll=1.0):
updated = await self._update_album(album_id, {"monitored": True})
if updated and updated.get("monitored"):
return
if attempt < max_attempts - 1:
logger.warning(f"Monitoring verification failed, attempt {attempt + 1}/{max_attempts}")
await asyncio.sleep(5.0)
logger.warning("Album monitoring verification failed, attempt %d/%d", attempt + 1, max_attempts)
await asyncio.sleep(2.0 + (attempt * 2.0))
except Exception as e: # noqa: BLE001
if attempt == max_attempts - 1:
raise ExternalServiceError(
f"Failed to set monitoring status after {max_attempts} attempts: {str(e)}"
)
await asyncio.sleep(5.0)
await asyncio.sleep(3.0)
async def _unmonitor_auto_monitored_albums(
self,
artist_id: int,
requested_mbid: str,
requested_album_id: int,
pre_add_monitored_ids: set[int],
) -> None:
"""Unmonitor albums that Lidarr auto-monitored during artist add (Aurral pattern)."""
try:
current_albums = await self._get(
"/api/v1/album", params={"artistId": artist_id}
)
if not isinstance(current_albums, list):
return
to_unmonitor = [
a["id"]
for a in current_albums
if a.get("monitored")
and a["id"] != requested_album_id
and a["id"] not in pre_add_monitored_ids
]
if to_unmonitor:
await self._put(
"/api/v1/album/monitor",
{"albumIds": to_unmonitor, "monitored": False},
)
logger.info(
"Unmonitored %d auto-monitored albums for artist %d (kept requested %s)",
len(to_unmonitor), artist_id, requested_mbid[:8],
)
except ExternalServiceError as exc:
logger.warning("Failed to unmonitor auto-monitored albums: %s", exc)