diff --git a/backend/api/v1/routes/albums.py b/backend/api/v1/routes/albums.py
index 411f5ef..f92ce3d 100644
--- a/backend/api/v1/routes/albums.py
+++ b/backend/api/v1/routes/albums.py
@@ -1,4 +1,4 @@
-from typing import Optional
+import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
from core.exceptions import ClientDisconnectedError
@@ -11,9 +11,12 @@ from services.album_enrichment_service import AlbumEnrichmentService
from services.navidrome_library_service import NavidromeLibraryService
from infrastructure.validators import is_unknown_mbid
from infrastructure.degradation import try_get_degradation_context
-from infrastructure.msgspec_fastapi import MsgSpecRoute
+from infrastructure.msgspec_fastapi import AppStruct, MsgSpecBody, MsgSpecRoute
import msgspec.structs
+import msgspec
+
+logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/albums", tags=["album"])
@@ -151,6 +154,44 @@ async def get_more_by_artist(
return await discovery_service.get_more_by_artist(artist_id, album_id, count)
+class MonitorRequest(AppStruct):
+ monitored: bool
+
+
+@router.put("/{album_id}/monitor")
+async def set_album_monitored(
+ album_id: str,
+ body: MonitorRequest = MsgSpecBody(MonitorRequest),
+ album_service: AlbumService = Depends(get_album_service),
+):
+ if is_unknown_mbid(album_id):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Invalid or unknown album ID: {album_id}"
+ )
+ try:
+ success = await album_service.set_album_monitored(album_id, body.monitored)
+ if not success:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Album not found in Lidarr: {album_id}"
+ )
+ return {"success": True}
+ except HTTPException:
+ raise
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid album request"
+ )
+ except Exception as e: # noqa: BLE001
+ logger.error(f"Failed to update monitoring status: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update monitoring status"
+ )
+
+
@router.get("/{album_id}/lastfm", response_model=LastFmAlbumEnrichment)
async def get_album_lastfm_enrichment(
album_id: str,
diff --git a/backend/api/v1/routes/library.py b/backend/api/v1/routes/library.py
index 9fc72a0..88c3753 100644
--- a/backend/api/v1/routes/library.py
+++ b/backend/api/v1/routes/library.py
@@ -112,11 +112,12 @@ async def get_library_stats(
async def get_library_mbids(
library_service: LibraryService = Depends(get_library_service)
):
- mbids, requested = await asyncio.gather(
+ mbids, requested, monitored = await asyncio.gather(
library_service.get_library_mbids(),
library_service.get_requested_mbids(),
+ library_service.get_monitored_mbids(),
)
- return LibraryMbidsResponse(mbids=mbids, requested_mbids=requested)
+ return LibraryMbidsResponse(mbids=mbids, requested_mbids=requested, monitored_mbids=monitored)
@router.get("/grouped", response_model=LibraryGroupedResponse)
diff --git a/backend/api/v1/schemas/album.py b/backend/api/v1/schemas/album.py
index 8dd82a2..64afd3b 100644
--- a/backend/api/v1/schemas/album.py
+++ b/backend/api/v1/schemas/album.py
@@ -16,6 +16,7 @@ class AlbumBasicInfo(AppStruct):
disambiguation: str | None = None
in_library: bool = False
requested: bool = False
+ monitored: bool = False
cover_url: str | None = None
album_thumb_url: str | None = None
diff --git a/backend/api/v1/schemas/discover.py b/backend/api/v1/schemas/discover.py
index da35614..6340e21 100644
--- a/backend/api/v1/schemas/discover.py
+++ b/backend/api/v1/schemas/discover.py
@@ -24,6 +24,7 @@ class DiscoverQueueItemLight(AppStruct):
cover_url: str | None = None
is_wildcard: bool = False
in_library: bool = False
+ monitored: bool = False
class DiscoverQueueEnrichment(AppStruct):
diff --git a/backend/api/v1/schemas/discovery.py b/backend/api/v1/schemas/discovery.py
index 45d4ad5..f1a130c 100644
--- a/backend/api/v1/schemas/discovery.py
+++ b/backend/api/v1/schemas/discovery.py
@@ -41,6 +41,7 @@ class TopAlbum(AppStruct):
listen_count: int = 0
in_library: bool = False
requested: bool = False
+ monitored: bool = False
cover_url: str | None = None
@@ -58,6 +59,7 @@ class DiscoveryAlbum(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
+ monitored: bool = False
cover_url: str | None = None
diff --git a/backend/api/v1/schemas/home.py b/backend/api/v1/schemas/home.py
index c84af7e..eafea4f 100644
--- a/backend/api/v1/schemas/home.py
+++ b/backend/api/v1/schemas/home.py
@@ -22,6 +22,7 @@ class HomeAlbum(AppStruct):
listen_count: int | None = None
in_library: bool = False
requested: bool = False
+ monitored: bool = False
source: str | None = None
diff --git a/backend/api/v1/schemas/library.py b/backend/api/v1/schemas/library.py
index 3550932..4e741c8 100644
--- a/backend/api/v1/schemas/library.py
+++ b/backend/api/v1/schemas/library.py
@@ -73,6 +73,7 @@ class SyncLibraryResponse(AppStruct):
class LibraryMbidsResponse(AppStruct):
mbids: list[str] = []
requested_mbids: list[str] = []
+ monitored_mbids: list[str] = []
class LibraryGroupedResponse(AppStruct):
diff --git a/backend/api/v1/schemas/search.py b/backend/api/v1/schemas/search.py
index 3daf331..576aa7b 100644
--- a/backend/api/v1/schemas/search.py
+++ b/backend/api/v1/schemas/search.py
@@ -64,6 +64,7 @@ class SuggestResult(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
+ monitored: bool = False
disambiguation: str | None = None
score: int = 0
diff --git a/backend/infrastructure/cache/cache_keys.py b/backend/infrastructure/cache/cache_keys.py
index d09dc56..d9a6234 100644
--- a/backend/infrastructure/cache/cache_keys.py
+++ b/backend/infrastructure/cache/cache_keys.py
@@ -154,6 +154,10 @@ def lidarr_requested_mbids_key() -> str:
return f"{LIDARR_REQUESTED_PREFIX}_mbids"
+def lidarr_monitored_mbids_key() -> str:
+ return f"{LIDARR_PREFIX}monitored_mbids"
+
+
def lidarr_status_key() -> str:
return f"{LIDARR_PREFIX}status"
diff --git a/backend/infrastructure/cache/disk_cache.py b/backend/infrastructure/cache/disk_cache.py
index 4e352ba..aacdf31 100644
--- a/backend/infrastructure/cache/disk_cache.py
+++ b/backend/infrastructure/cache/disk_cache.py
@@ -18,6 +18,8 @@ def _decode_json(text: str) -> Any:
class DiskMetadataCache:
+ _CACHE_VERSION = "v3"
+
def __init__(
self,
base_path: Path,
@@ -57,7 +59,7 @@ class DiskMetadataCache:
@staticmethod
def _cache_hash(identifier: str) -> str:
- return hashlib.sha1(identifier.encode()).hexdigest()
+ return hashlib.sha1(f"{DiskMetadataCache._CACHE_VERSION}:{identifier}".encode()).hexdigest()
@staticmethod
def _meta_path(file_path: Path) -> Path:
diff --git a/backend/models/album.py b/backend/models/album.py
index 7f89442..5a85e7e 100644
--- a/backend/models/album.py
+++ b/backend/models/album.py
@@ -26,6 +26,7 @@ class AlbumInfo(AppStruct):
total_length: int | None = None
in_library: bool = False
requested: bool = False
+ monitored: bool = False
cover_url: str | None = None
album_thumb_url: str | None = None
album_back_url: str | None = None
diff --git a/backend/models/artist.py b/backend/models/artist.py
index a15782b..8a7c61e 100644
--- a/backend/models/artist.py
+++ b/backend/models/artist.py
@@ -26,6 +26,7 @@ class ReleaseItem(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
+ monitored: bool = False
class ArtistInfo(AppStruct):
diff --git a/backend/models/search.py b/backend/models/search.py
index 187047c..f5f00c9 100644
--- a/backend/models/search.py
+++ b/backend/models/search.py
@@ -9,6 +9,7 @@ class SearchResult(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
+ monitored: bool = False
cover_url: str | None = None
album_thumb_url: str | None = None
thumb_url: str | None = None
diff --git a/backend/repositories/lidarr/album.py b/backend/repositories/lidarr/album.py
index 94d6aba..6f8bd93 100644
--- a/backend/repositories/lidarr/album.py
+++ b/backend/repositories/lidarr/album.py
@@ -296,6 +296,20 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
logger.error(f"Failed to delete album {album_id}: {e}")
raise
+ async def set_monitored(self, album_mbid: str, monitored: bool) -> bool:
+ lidarr_album = await self._get_album_by_foreign_id(album_mbid)
+ if not lidarr_album:
+ return False
+ album_id = lidarr_album.get("id")
+ artist_mbid = (lidarr_album.get("artist", {}).get("foreignArtistId") or "")
+ if not album_id:
+ return False
+ lock = _get_artist_lock(artist_mbid)
+ async with lock:
+ await self._update_album(album_id, {"monitored": monitored})
+ await self._invalidate_album_list_caches()
+ return True
+
async def add_album(self, musicbrainz_id: str, artist_repo) -> dict:
t0 = time.monotonic()
if not musicbrainz_id or not isinstance(musicbrainz_id, str):
diff --git a/backend/repositories/lidarr/base.py b/backend/repositories/lidarr/base.py
index 20ad940..5ad47af 100644
--- a/backend/repositories/lidarr/base.py
+++ b/backend/repositories/lidarr/base.py
@@ -5,7 +5,7 @@ import time
from typing import Any, Optional
from core.config import Settings
from core.exceptions import ExternalServiceError
-from infrastructure.cache.cache_keys import lidarr_raw_albums_key, lidarr_requested_mbids_key, LIDARR_PREFIX
+from infrastructure.cache.cache_keys import lidarr_raw_albums_key, lidarr_requested_mbids_key, lidarr_monitored_mbids_key, LIDARR_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.http.deduplication import get_deduplicator
from infrastructure.resilience.retry import with_retry, CircuitBreaker
@@ -141,6 +141,7 @@ class LidarrBase:
await self._cache.delete(lidarr_raw_albums_key())
await self._cache.clear_prefix(f"{LIDARR_PREFIX}library:")
await self._cache.delete(lidarr_requested_mbids_key())
+ await self._cache.delete(lidarr_monitored_mbids_key())
async def _post(self, endpoint: str, data: dict[str, Any]) -> Any:
return await self._request("POST", endpoint, json_data=data)
diff --git a/backend/repositories/lidarr/library.py b/backend/repositories/lidarr/library.py
index 65df908..183ccbe 100644
--- a/backend/repositories/lidarr/library.py
+++ b/backend/repositories/lidarr/library.py
@@ -9,6 +9,7 @@ from infrastructure.cache.cache_keys import (
lidarr_artist_mbids_key,
lidarr_library_grouped_key,
lidarr_requested_mbids_key,
+ lidarr_monitored_mbids_key,
)
from .base import LidarrBase
@@ -31,11 +32,17 @@ class LidarrLibraryRepository(LidarrBase):
for item in data:
is_monitored = item.get("monitored", False)
+ statistics = item.get("statistics", {})
+ has_files = statistics.get("trackFileCount", 0) > 0
if not is_monitored and not include_unmonitored:
filtered_count += 1
continue
+ if not has_files:
+ filtered_count += 1
+ continue
+
artist_data = item.get("artist", {})
artist = artist_data.get("artistName", "Unknown")
artist_mbid = artist_data.get("foreignArtistId")
@@ -92,11 +99,17 @@ class LidarrLibraryRepository(LidarrBase):
for item in albums_data:
is_monitored = item.get("monitored", False)
+ statistics = item.get("statistics", {})
+ has_files = statistics.get("trackFileCount", 0) > 0
if not is_monitored and not include_unmonitored:
filtered_count += 1
continue
+ if not has_files:
+ filtered_count += 1
+ continue
+
artist_data = item.get("artist", {})
artist_mbid = artist_data.get("foreignArtistId")
artist_name = artist_data.get("artistName", "Unknown")
@@ -139,6 +152,10 @@ class LidarrLibraryRepository(LidarrBase):
grouped: dict[str, list[LibraryGroupedAlbum]] = {}
for item in data:
+ statistics = item.get("statistics", {})
+ if statistics.get("trackFileCount", 0) == 0:
+ continue
+
artist = item.get("artist", {}).get("artistName", "Unknown")
title = item.get("title")
year = None
@@ -182,9 +199,6 @@ class LidarrLibraryRepository(LidarrBase):
data = await self._get_all_albums_raw()
ids: set[str] = set()
for item in data:
- if not item.get("monitored", False):
- continue
-
statistics = item.get("statistics", {})
track_file_count = statistics.get("trackFileCount", 0)
if track_file_count == 0:
@@ -212,7 +226,9 @@ class LidarrLibraryRepository(LidarrBase):
data = await self._get("/api/v1/artist")
ids: set[str] = set()
for item in data:
- if not item.get("monitored", False):
+ is_monitored = item.get("monitored", False)
+ has_files = item.get("statistics", {}).get("trackFileCount", 0) > 0
+ if not is_monitored and not has_files:
continue
mbid = item.get("foreignArtistId") or item.get("mbId")
if isinstance(mbid, str):
@@ -232,7 +248,7 @@ class LidarrLibraryRepository(LidarrBase):
async def get_monitored_no_files_mbids(self) -> set[str]:
"""Return monitored Lidarr albums that have no downloaded track files."""
- cache_key = lidarr_requested_mbids_key()
+ cache_key = lidarr_monitored_mbids_key()
cached_result = await self._cache.get(cache_key)
if cached_result is not None:
diff --git a/backend/repositories/protocols/lidarr.py b/backend/repositories/protocols/lidarr.py
index 9050f9c..971bda1 100644
--- a/backend/repositories/protocols/lidarr.py
+++ b/backend/repositories/protocols/lidarr.py
@@ -49,6 +49,9 @@ class LidarrRepositoryProtocol(Protocol):
async def get_requested_mbids(self) -> set[str]:
...
+ async def get_monitored_no_files_mbids(self) -> set[str]:
+ ...
+
async def delete_album(self, album_id: int, delete_files: bool = False) -> bool:
...
@@ -92,3 +95,6 @@ class LidarrRepositoryProtocol(Protocol):
self, artist_mbid: str, *, monitored: bool, monitor_new_items: str = "none",
) -> dict[str, Any]:
...
+
+ async def set_monitored(self, album_mbid: str, monitored: bool) -> bool:
+ ...
diff --git a/backend/services/album_service.py b/backend/services/album_service.py
index 0e1a2c2..19ff069 100644
--- a/backend/services/album_service.py
+++ b/backend/services/album_service.py
@@ -8,7 +8,7 @@ from repositories.protocols import LidarrRepositoryProtocol, MusicBrainzReposito
from services.preferences_service import PreferencesService
from services.album_utils import parse_year, find_primary_release, get_ranked_releases, extract_artist_info, extract_tracks, extract_label, build_album_basic_info, lidarr_to_basic_info, mb_to_basic_info
from infrastructure.persistence import LibraryDB
-from infrastructure.cache.cache_keys import ALBUM_INFO_PREFIX, LIDARR_ALBUM_DETAILS_PREFIX
+from infrastructure.cache.cache_keys import ALBUM_INFO_PREFIX, LIDARR_ALBUM_DETAILS_PREFIX, ARTIST_INFO_PREFIX, LIDARR_ARTIST_ALBUMS_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.cache.disk_cache import DiskMetadataCache
from infrastructure.cover_urls import prefer_release_group_cover_url
@@ -166,10 +166,10 @@ class AlbumService:
await self._disk_cache.set_album(release_group_id, album_info, is_monitored=album_info.in_library, ttl_seconds=ttl if not album_info.in_library else None)
def _check_lidarr_in_library(self, lidarr_album: dict | None) -> bool:
- if lidarr_album and lidarr_album.get("monitored", False):
- statistics = lidarr_album.get("statistics", {})
- return statistics.get("trackFileCount", 0) > 0
- return False
+ if lidarr_album is None:
+ return False
+ statistics = lidarr_album.get("statistics", {})
+ return statistics.get("trackFileCount", 0) > 0
async def warm_full_album_cache(self, release_group_id: str) -> None:
"""Fire-and-forget: populate the full album_info cache if missing."""
@@ -192,7 +192,48 @@ class AlbumService:
return await self.get_album_info(release_group_id)
- async def get_album_info(self, release_group_id: str, monitored_mbids: set[str] = None) -> AlbumInfo:
+ async def set_album_monitored(self, release_group_id: str, monitored: bool) -> bool:
+ try:
+ release_group_id = validate_mbid(release_group_id, "album")
+ except ValueError as e:
+ logger.error(f"Invalid album MBID: {e}")
+ raise
+
+ success = await self._lidarr_repo.set_monitored(release_group_id, monitored)
+ if success:
+ await self._cache.delete(f"{ALBUM_INFO_PREFIX}{release_group_id}")
+ await self._cache.delete(f"{LIDARR_ALBUM_DETAILS_PREFIX}{release_group_id}")
+ await self._disk_cache.delete_album(release_group_id)
+ try:
+ lidarr_album = await self._lidarr_repo.get_album_details(release_group_id)
+ if lidarr_album:
+ artist_data = lidarr_album.get("artist", {})
+ artist_mbid = artist_data.get("foreignArtistId")
+ if artist_mbid:
+ await self._cache.delete(f"{ARTIST_INFO_PREFIX}{artist_mbid}")
+ await self._cache.delete(f"{LIDARR_ARTIST_ALBUMS_PREFIX}{artist_mbid}")
+ await self._disk_cache.delete_artist(artist_mbid)
+ except Exception as e: # noqa: BLE001
+ logger.warning(f"Failed to invalidate artist caches for {release_group_id}: {e}")
+ try:
+ existing = await self._library_db.get_album_by_mbid(release_group_id)
+ if existing:
+ album_data: dict = {
+ "mbid": release_group_id,
+ "monitored": monitored,
+ "artist_mbid": existing.get("artist_mbid"),
+ "artist_name": existing.get("artist_name"),
+ "title": existing.get("title"),
+ "year": existing.get("year"),
+ "cover_url": existing.get("cover_url"),
+ "date_added": existing.get("date_added"),
+ }
+ await self._library_db.upsert_album(album_data)
+ except Exception as e: # noqa: BLE001
+ logger.warning(f"Failed to update library_db for {release_group_id}: {e}")
+ return success
+
+ async def get_album_info(self, release_group_id: str, library_mbids: set[str] = None) -> AlbumInfo:
try:
release_group_id = validate_mbid(release_group_id, "album")
except ValueError as e:
@@ -215,7 +256,7 @@ class AlbumService:
future: asyncio.Future[AlbumInfo] = loop.create_future()
self._album_in_flight[release_group_id] = future
try:
- album_info = await self._do_get_album_info(release_group_id, cache_key, monitored_mbids)
+ album_info = await self._do_get_album_info(release_group_id, cache_key, library_mbids)
if not future.done():
future.set_result(album_info)
return album_info
@@ -232,14 +273,16 @@ class AlbumService:
raise ResourceNotFoundError(f"Failed to get album info: {e}")
async def _do_get_album_info(
- self, release_group_id: str, cache_key: str, monitored_mbids: set[str] | None
+ self, release_group_id: str, cache_key: str, library_mbids: set[str] | None
) -> AlbumInfo:
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None
in_library = self._check_lidarr_in_library(lidarr_album)
if in_library and lidarr_album:
album_info = await self._build_album_from_lidarr(release_group_id, lidarr_album)
else:
- album_info = await self._build_album_from_musicbrainz(release_group_id, monitored_mbids)
+ album_info = await self._build_album_from_musicbrainz(release_group_id, library_mbids)
+ if lidarr_album is not None:
+ album_info.monitored = lidarr_album.get("monitored", False)
album_info = await self._apply_audiodb_album_images(
album_info, release_group_id, album_info.artist_name, album_info.title,
allow_fetch=True, is_monitored=album_info.in_library,
@@ -286,14 +329,21 @@ class AlbumService:
disambiguation=cached_album_info.disambiguation,
in_library=cached_album_info.in_library,
requested=is_requested and not cached_album_info.in_library,
+ monitored=cached_album_info.monitored,
cover_url=cached_album_info.cover_url,
album_thumb_url=album_thumb,
)
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None
in_library = self._check_lidarr_in_library(lidarr_album)
- if lidarr_album and lidarr_album.get("monitored", False):
+
+ is_monitored = False
+ if lidarr_album is not None:
+ is_monitored = lidarr_album.get("monitored", False)
+
+ if lidarr_album:
basic = AlbumBasicInfo(**lidarr_to_basic_info(lidarr_album, release_group_id, in_library, is_requested=is_requested))
+ basic.monitored = is_monitored
if not basic.album_thumb_url:
basic.album_thumb_url = await self._get_audiodb_album_thumb(
release_group_id, basic.artist_name, basic.title,
@@ -305,6 +355,7 @@ class AlbumService:
cached_album = await self._library_db.get_album_by_mbid(release_group_id)
in_library = cached_album is not None
basic = AlbumBasicInfo(**mb_to_basic_info(release_group, release_group_id, in_library, is_requested))
+ basic.monitored = is_monitored
basic.album_thumb_url = await self._get_audiodb_album_thumb(
release_group_id, basic.artist_name, basic.title,
allow_fetch=False,
@@ -338,7 +389,7 @@ class AlbumService:
)
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None
- in_library = lidarr_album is not None and lidarr_album.get("monitored", False)
+ in_library = self._check_lidarr_in_library(lidarr_album)
if in_library and lidarr_album:
album_id = lidarr_album.get("id")
@@ -428,9 +479,9 @@ class AlbumService:
return rg_result
- async def _check_in_library(self, release_group_id: str, monitored_mbids: set[str] = None) -> bool:
- if monitored_mbids is not None:
- return release_group_id.lower() in monitored_mbids
+ async def _check_in_library(self, release_group_id: str, library_mbids: set[str] = None) -> bool:
+ if library_mbids is not None:
+ return release_group_id.lower() in library_mbids
library_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True)
return release_group_id.lower() in library_mbids
@@ -557,13 +608,14 @@ class AlbumService:
total_tracks=len(tracks),
total_length=total_length if total_length > 0 else None,
in_library=True,
+ monitored=lidarr_album.get("monitored", False),
cover_url=cover_url,
)
async def _build_album_from_musicbrainz(
self,
release_group_id: str,
- monitored_mbids: set[str] = None
+ library_mbids: set[str] = None
) -> AlbumInfo:
cached_album = await self._library_db.get_album_by_mbid(release_group_id)
in_library = cached_album is not None
@@ -573,7 +625,7 @@ class AlbumService:
artist_name, artist_id = extract_artist_info(release_group)
if not in_library:
- in_library = await self._check_in_library(release_group_id, monitored_mbids)
+ in_library = await self._check_in_library(release_group_id, library_mbids)
basic_info = self._build_basic_info(
release_group, release_group_id, artist_name, artist_id, in_library
diff --git a/backend/services/album_utils.py b/backend/services/album_utils.py
index 6bd9257..d2a114f 100644
--- a/backend/services/album_utils.py
+++ b/backend/services/album_utils.py
@@ -129,6 +129,7 @@ def lidarr_to_basic_info(lidarr_album: dict, release_group_id: str, in_library:
"disambiguation": lidarr_album.get("disambiguation"),
"in_library": in_library,
"requested": is_requested and not in_library,
+ "monitored": lidarr_album.get("monitored", False),
"cover_url": lidarr_album.get("cover_url"),
}
@@ -146,5 +147,6 @@ def mb_to_basic_info(release_group: dict, release_group_id: str, in_library: boo
"disambiguation": release_group.get("disambiguation"),
"in_library": in_library,
"requested": is_requested and not in_library,
+ "monitored": False,
"cover_url": None,
}
diff --git a/backend/services/artist_service.py b/backend/services/artist_service.py
index afed4db..0993632 100644
--- a/backend/services/artist_service.py
+++ b/backend/services/artist_service.py
@@ -218,8 +218,15 @@ class ArtistService:
library_album_mbids: dict[str, Any] | None,
) -> ArtistInfo:
lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id) if self._lidarr_repo.is_configured() else None
- in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False)
- if in_library and lidarr_artist:
+ use_lidarr_data = False
+ if lidarr_artist is not None:
+ artist_monitored = lidarr_artist.get("monitored", False)
+ has_albums_with_files = any(
+ a.get("statistics", {}).get("trackFileCount", 0) > 0
+ for a in (lidarr_artist.get("albums") or [])
+ )
+ use_lidarr_data = artist_monitored or has_albums_with_files
+ if use_lidarr_data and lidarr_artist:
artist_info = await self._build_artist_from_lidarr(artist_id, lidarr_artist, library_album_mbids)
else:
artist_info = await self._build_artist_from_musicbrainz(artist_id, library_artist_mbids, library_album_mbids)
@@ -390,11 +397,11 @@ class ArtistService:
include_extended: bool = True,
include_releases: bool = True,
) -> ArtistInfo:
- mb_artist, library_mbids, album_mbids, requested_mbids = await self._fetch_artist_data(
+ mb_artist, library_mbids, album_mbids, requested_mbids, monitored_mbids = await self._fetch_artist_data(
artist_id, library_artist_mbids, library_album_mbids
)
in_library = artist_id.lower() in library_mbids
- albums, singles, eps = (await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids)) if include_releases else ([], [], [])
+ albums, singles, eps = (await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids, monitored_mbids)) if include_releases else ([], [], [])
description, image = (await self._fetch_wikidata_info(mb_artist)) if include_extended else (None, None)
info = build_base_artist_info(
mb_artist, artist_id, in_library,
@@ -440,9 +447,10 @@ class ArtistService:
if not self._lidarr_repo.is_configured():
return
try:
- library_mbids, requested_mbids, artist_mbids = await asyncio.gather(
+ library_mbids, requested_mbids, monitored_mbids, artist_mbids = await asyncio.gather(
self._lidarr_repo.get_library_mbids(include_release_ids=False),
self._lidarr_repo.get_requested_mbids(),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
self._lidarr_repo.get_artist_mbids(),
)
for release_list in (artist_info.albums, artist_info.singles, artist_info.eps):
@@ -452,6 +460,7 @@ class ArtistService:
continue
rg.in_library = rg_id in library_mbids
rg.requested = rg_id in requested_mbids and not rg.in_library
+ rg.monitored = rg_id in monitored_mbids
mbid_lower = artist_info.musicbrainz_id.lower()
is_in_artist_mbids = mbid_lower in artist_mbids
artist_info.in_library = is_in_artist_mbids
@@ -530,12 +539,20 @@ class ArtistService:
) -> ArtistReleases:
try:
lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id)
- in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False)
+ has_lidarr_data = False
+ if lidarr_artist is not None:
+ artist_monitored = lidarr_artist.get("monitored", False)
+ has_albums_with_files = any(
+ a.get("statistics", {}).get("trackFileCount", 0) > 0
+ for a in (lidarr_artist.get("albums") or [])
+ )
+ has_lidarr_data = artist_monitored or has_albums_with_files
- album_mbids, requested_mbids, cache_mbids = await asyncio.gather(
+ album_mbids, requested_mbids, cache_mbids, monitored_mbids = await asyncio.gather(
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_requested_mbids(),
self._get_library_cache_mbids(),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
)
album_mbids = album_mbids | cache_mbids
@@ -543,7 +560,7 @@ class ArtistService:
included_primary_types = set(t.lower() for t in prefs.primary_types)
included_secondary_types = set(t.lower() for t in prefs.secondary_types)
- if in_library:
+ if has_lidarr_data:
if offset == 0:
lidarr_albums = await self._lidarr_repo.get_artist_albums(artist_id)
albums, singles, eps = self._categorize_lidarr_albums(lidarr_albums, album_mbids, requested_mbids=requested_mbids)
@@ -567,7 +584,7 @@ class ArtistService:
return await self._filter_aware_release_page(
artist_id, offset, limit, album_mbids, requested_mbids,
- included_primary_types, included_secondary_types,
+ included_primary_types, included_secondary_types, monitored_mbids,
)
except Exception as e: # noqa: BLE001
logger.error(f"Error fetching releases for artist {artist_id} at offset {offset}: {e}")
@@ -586,6 +603,7 @@ class ArtistService:
requested_mbids: set[str],
included_primary_types: set[str],
included_secondary_types: set[str],
+ monitored_mbids: set[str] | None = None,
) -> ArtistReleases:
if not included_primary_types:
return ArtistReleases(
@@ -618,7 +636,7 @@ class ArtistService:
temp_artist = {"release-group-list": release_groups}
page_albums, page_singles, page_eps = categorize_release_groups(
temp_artist, album_mbids, included_primary_types,
- included_secondary_types, requested_mbids,
+ included_secondary_types, requested_mbids, monitored_mbids,
)
for item in page_albums:
@@ -669,24 +687,27 @@ class ArtistService:
artist_id: str,
library_artist_mbids: set[str] = None,
library_album_mbids: dict[str, Any] = None
- ) -> tuple[dict, set[str], set[str], set[str]]:
+ ) -> tuple[dict, set[str], set[str], set[str], set[str]]:
if library_artist_mbids is not None and library_album_mbids is not None:
mb_artist = await self._mb_repo.get_artist_by_id(artist_id)
library_mbids = library_artist_mbids
album_mbids = library_album_mbids
- requested_result = await asyncio.gather(
+ requested_result, monitored_result = await asyncio.gather(
self._lidarr_repo.get_requested_mbids(),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
- requested_mbids = requested_result[0] if not isinstance(requested_result[0], BaseException) else set()
- if isinstance(requested_result[0], BaseException):
- logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result[0]}")
+ requested_mbids = requested_result if not isinstance(requested_result, BaseException) else set()
+ monitored_mbids = monitored_result if not isinstance(monitored_result, BaseException) else set()
+ if isinstance(requested_result, BaseException):
+ logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result}")
else:
mb_artist, *lidarr_results = await asyncio.gather(
self._mb_repo.get_artist_by_id(artist_id),
self._lidarr_repo.get_artist_mbids(),
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_requested_mbids(),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
if isinstance(mb_artist, BaseException):
@@ -698,16 +719,15 @@ class ArtistService:
library_mbids = lidarr_results[0] if not isinstance(lidarr_results[0], BaseException) else set()
album_mbids = lidarr_results[1] if not isinstance(lidarr_results[1], BaseException) else set()
requested_mbids = lidarr_results[2] if not isinstance(lidarr_results[2], BaseException) else set()
+ monitored_mbids = lidarr_results[3] if not isinstance(lidarr_results[3], BaseException) else set()
- # Supplement with LibraryDB so monitored albums (even with trackFileCount=0)
- # are recognised as "in library", consistent with the Library page.
cache_mbids = await self._get_library_cache_mbids()
album_mbids = album_mbids | cache_mbids
if not mb_artist:
raise ResourceNotFoundError("Artist not found")
- return mb_artist, library_mbids, album_mbids, requested_mbids
+ return mb_artist, library_mbids, album_mbids, requested_mbids, monitored_mbids
def _build_external_links(self, mb_artist: dict[str, Any]) -> list[ExternalLink]:
external_links_data = extract_external_links(mb_artist)
@@ -720,7 +740,8 @@ class ArtistService:
self,
mb_artist: dict[str, Any],
album_mbids: set[str],
- requested_mbids: set[str] = None
+ requested_mbids: set[str] = None,
+ monitored_mbids: set[str] = None,
) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]:
prefs = self._preferences_service.get_preferences()
included_primary_types = set(t.lower() for t in prefs.primary_types)
@@ -730,7 +751,8 @@ class ArtistService:
album_mbids,
included_primary_types,
included_secondary_types,
- requested_mbids or set()
+ requested_mbids or set(),
+ monitored_mbids or set(),
)
async def _fetch_wikidata_info(self, mb_artist: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
diff --git a/backend/services/artist_utils.py b/backend/services/artist_utils.py
index b8d9713..6de6be9 100644
--- a/backend/services/artist_utils.py
+++ b/backend/services/artist_utils.py
@@ -106,11 +106,14 @@ def categorize_release_groups(
included_primary_types: Optional[set[str]] = None,
included_secondary_types: Optional[set[str]] = None,
requested_mbids: Optional[set[str]] = None,
+ monitored_mbids: Optional[set[str]] = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
if included_primary_types is None:
included_primary_types = {"album", "single", "ep", "broadcast", "other"}
if requested_mbids is None:
requested_mbids = set()
+ if monitored_mbids is None:
+ monitored_mbids = set()
albums: list[ReleaseItem] = []
singles: list[ReleaseItem] = []
eps: list[ReleaseItem] = []
@@ -130,6 +133,7 @@ def categorize_release_groups(
rg_id_lower = rg_id.lower() if rg_id else ""
in_library = rg_id_lower in album_mbids if rg_id else False
requested = rg_id_lower in requested_mbids if rg_id and not in_library else False
+ is_monitored = rg_id_lower in monitored_mbids if rg_id and not in_library and not requested else False
rg_data = ReleaseItem(
id=rg_id,
title=rg.get("title"),
@@ -137,6 +141,7 @@ def categorize_release_groups(
first_release_date=rg.get("first-release-date"),
in_library=in_library,
requested=requested,
+ monitored=is_monitored,
)
if date := rg_data.first_release_date:
try:
@@ -182,6 +187,7 @@ def categorize_lidarr_albums(
track_file_count = album.get("track_file_count", 0)
in_library = track_file_count > 0 or (mbid_lower in _cache_mbids)
requested = mbid_lower in _requested_mbids and not in_library
+ is_monitored = album.get("monitored", False) and not in_library and not requested
album_data = ReleaseItem(
id=mbid,
title=album.get("title"),
@@ -190,6 +196,7 @@ def categorize_lidarr_albums(
year=album.get("year"),
in_library=in_library,
requested=requested,
+ monitored=is_monitored,
)
if album_type == "album":
albums.append(album_data)
diff --git a/backend/services/discover/homepage_service.py b/backend/services/discover/homepage_service.py
index 52844a7..0d3338b 100644
--- a/backend/services/discover/homepage_service.py
+++ b/backend/services/discover/homepage_service.py
@@ -167,6 +167,13 @@ class DiscoverHomepageService:
library_mbids = await self._mbid.get_library_artist_mbids(lidarr_configured)
+ monitored_mbids: set[str] = set()
+ if lidarr_configured:
+ try:
+ monitored_mbids = await self._lidarr_repo.get_monitored_no_files_mbids()
+ except Exception: # noqa: BLE001
+ logger.debug("Failed to fetch monitored MBIDs for discover page")
+
seed_artists = await self._get_seed_artists(
lb_enabled, username, jf_enabled,
resolved_source=resolved_source,
@@ -214,8 +221,8 @@ class DiscoverHomepageService:
tasks["jf_most_played"] = self._jf_repo.get_most_played_artists(limit=50)
if lidarr_configured:
- tasks["library_artists"] = self._lidarr_repo.get_artists_from_library()
- tasks["library_albums"] = self._lidarr_repo.get_library()
+ tasks["library_artists"] = self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
+ tasks["library_albums"] = self._lidarr_repo.get_library(include_unmonitored=True)
results = await self._execute_tasks(tasks)
@@ -231,15 +238,15 @@ class DiscoverHomepageService:
)
await self._enrich_because_sections_audiodb(response.because_you_listen_to)
- response.fresh_releases = self._build_fresh_releases(results, library_mbids)
+ response.fresh_releases = self._build_fresh_releases(results, library_mbids, monitored_mbids)
post_tasks: dict[str, Any] = {
- "missing_essentials": self._build_missing_essentials(results, library_mbids),
+ "missing_essentials": self._build_missing_essentials(results, library_mbids, monitored_mbids),
"lastfm_weekly_album_chart": self._build_lastfm_weekly_album_chart(
- results, library_mbids
+ results, library_mbids, monitored_mbids
),
"lastfm_recent_scrobbles": self._build_lastfm_recent_scrobbles(
- results, library_mbids
+ results, library_mbids, monitored_mbids
),
}
if resolved_source == "listenbrainz" and lb_enabled and username:
@@ -464,7 +471,8 @@ class DiscoverHomepageService:
return sections
def _build_fresh_releases(
- self, results: dict[str, Any], library_mbids: set[str]
+ self, results: dict[str, Any], library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
releases = results.get("lb_fresh")
if not releases:
@@ -475,16 +483,22 @@ class DiscoverHomepageService:
if isinstance(r, dict):
mbid = r.get("release_group_mbid", "")
artist_mbids = r.get("artist_mbids", [])
+ in_lib = mbid.lower() in library_mbids if isinstance(mbid, str) and mbid else False
+ is_monitored = (
+ not in_lib and bool(monitored_mbids) and isinstance(mbid, str) and mbid
+ and mbid.lower() in monitored_mbids
+ )
items.append(HomeAlbum(
mbid=mbid,
name=r.get("title", r.get("release_group_name", "Unknown")),
artist_name=r.get("artist_credit_name", r.get("artist_name", "")),
artist_mbid=artist_mbids[0] if artist_mbids else None,
listen_count=r.get("listen_count"),
- in_library=mbid.lower() in library_mbids if isinstance(mbid, str) and mbid else False,
+ in_library=in_lib,
+ monitored=is_monitored,
))
else:
- items.append(self._transformers.lb_release_to_home(r, library_mbids))
+ items.append(self._transformers.lb_release_to_home(r, library_mbids, monitored_mbids))
except Exception as e: # noqa: BLE001
logger.debug(f"Skipping fresh release item: {e}")
continue
@@ -498,7 +512,8 @@ class DiscoverHomepageService:
)
async def _build_missing_essentials(
- self, results: dict[str, Any], library_mbids: set[str]
+ self, results: dict[str, Any], library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
library_artists = results.get("library_artists") or []
library_albums = results.get("library_albums") or []
@@ -551,6 +566,7 @@ class DiscoverHomepageService:
artist_name=rg.artist_name,
listen_count=rg.listen_count,
in_library=False,
+ monitored=bool(monitored_mbids) and rg_mbid.lower() in monitored_mbids,
))
artist_missing += 1
@@ -904,6 +920,7 @@ class DiscoverHomepageService:
self,
results: dict[str, Any],
library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
albums = results.get("lfm_weekly_albums") or []
if not albums:
@@ -914,7 +931,7 @@ class DiscoverHomepageService:
items = []
for album in albums[:20]:
- home_album = self._transformers.lastfm_album_to_home(album, library_mbids)
+ home_album = self._transformers.lastfm_album_to_home(album, library_mbids, monitored_mbids)
if home_album and home_album.mbid:
home_album.mbid = rg_map.get(home_album.mbid, home_album.mbid)
items.append(home_album)
@@ -933,6 +950,7 @@ class DiscoverHomepageService:
self,
results: dict[str, Any],
library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
tracks = results.get("lfm_recent") or []
if not tracks:
@@ -944,7 +962,7 @@ class DiscoverHomepageService:
items = []
seen_album_mbids: set[str] = set()
for track in tracks[:30]:
- home_album = self._transformers.lastfm_recent_to_home(track, library_mbids)
+ home_album = self._transformers.lastfm_recent_to_home(track, library_mbids, monitored_mbids)
if home_album and home_album.mbid:
resolved = rg_map.get(home_album.mbid, home_album.mbid)
home_album.mbid = resolved
diff --git a/backend/services/discover/mbid_resolution_service.py b/backend/services/discover/mbid_resolution_service.py
index 9e274ad..2f6c2b8 100644
--- a/backend/services/discover/mbid_resolution_service.py
+++ b/backend/services/discover/mbid_resolution_service.py
@@ -223,7 +223,7 @@ class MbidResolutionService:
if not lidarr_configured:
return set()
try:
- artists = await self._lidarr_repo.get_artists_from_library()
+ artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
return {a.get("mbid", "").lower() for a in artists if a.get("mbid")}
except Exception: # noqa: BLE001
logger.warning("Failed to fetch library artists from Lidarr")
diff --git a/backend/services/home/charts_service.py b/backend/services/home/charts_service.py
index 365af34..fe1e6b7 100644
--- a/backend/services/home/charts_service.py
+++ b/backend/services/home/charts_service.py
@@ -109,15 +109,17 @@ class HomeChartsService:
self, genre: str, limit: int = 100, artist_offset: int = 0, album_offset: int = 0
) -> GenreDetailResponse:
lidarr_results = await asyncio.gather(
- self._lidarr_repo.get_artists_from_library(),
- self._lidarr_repo.get_library(),
+ self._lidarr_repo.get_artists_from_library(include_unmonitored=True),
+ self._lidarr_repo.get_library(include_unmonitored=True),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
- lidarr_failed = any(isinstance(r, BaseException) for r in lidarr_results)
+ lidarr_failed = any(isinstance(r, BaseException) for r in lidarr_results[:2])
if lidarr_failed:
logger.warning("Lidarr unavailable for genre '%s', proceeding with MusicBrainz data only", genre)
library_artists = lidarr_results[0] if not isinstance(lidarr_results[0], BaseException) else []
library_albums = lidarr_results[1] if not isinstance(lidarr_results[1], BaseException) else []
+ monitored_mbids = lidarr_results[2] if not isinstance(lidarr_results[2], BaseException) else set()
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
library_album_mbids = {a.musicbrainz_id.lower() for a in library_albums if a.musicbrainz_id}
library_section = None
@@ -177,6 +179,7 @@ class HomeChartsService:
image_url=None,
release_date=str(result.year) if result.year else None,
in_library=result.musicbrainz_id.lower() in library_album_mbids,
+ monitored=result.musicbrainz_id.lower() not in library_album_mbids and result.musicbrainz_id.lower() in monitored_mbids,
)
for result in mb_album_results
]
@@ -203,7 +206,7 @@ class HomeChartsService:
if resolved == "lastfm" and self._lfm_repo:
return await self._get_trending_artists_lastfm(limit)
- library_artists = await self._lidarr_repo.get_artists_from_library()
+ library_artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
ranges = ["this_week", "this_month", "this_year", "all_time"]
tasks = {r: self._lb_repo.get_sitewide_top_artists(range_=r, count=limit + 1) for r in ranges}
@@ -249,7 +252,7 @@ class HomeChartsService:
offset=offset,
)
library_artists, lb_artists = await asyncio.gather(
- self._lidarr_repo.get_artists_from_library(),
+ self._lidarr_repo.get_artists_from_library(include_unmonitored=True),
self._lb_repo.get_sitewide_top_artists(
range_=range_key, count=limit + 1, offset=offset
),
@@ -275,15 +278,19 @@ class HomeChartsService:
if resolved == "lastfm" and self._lfm_repo:
return await self._get_popular_albums_lastfm(limit)
- library_albums = await self._lidarr_repo.get_library()
+ library_albums = await self._lidarr_repo.get_library(include_unmonitored=True)
library_mbids = {(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id}
+ try:
+ monitored_mbids = await self._lidarr_repo.get_monitored_no_files_mbids()
+ except Exception: # noqa: BLE001
+ monitored_mbids = set()
ranges = ["this_week", "this_month", "this_year", "all_time"]
tasks = {r: self._lb_repo.get_sitewide_top_release_groups(range_=r, count=limit + 1) for r in ranges}
results = await self._execute_tasks(tasks)
response_data = {}
for r in ranges:
lb_albums = results.get(r) or []
- albums = [self._transformers.lb_release_to_home(a, library_mbids) for a in lb_albums]
+ albums = [self._transformers.lb_release_to_home(a, library_mbids, monitored_mbids) for a in lb_albums]
featured = albums[0] if albums else None
items = albums[1:limit] if len(albums) > 1 else []
response_data[r] = PopularTimeRange(
@@ -317,14 +324,17 @@ class HomeChartsService:
limit=limit,
offset=offset,
)
- library_albums, lb_albums = await asyncio.gather(
- self._lidarr_repo.get_library(),
+ library_albums, lb_albums, monitored_mbids_result = await asyncio.gather(
+ self._lidarr_repo.get_library(include_unmonitored=True),
self._lb_repo.get_sitewide_top_release_groups(
range_=range_key, count=limit + 1, offset=offset
),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
+ return_exceptions=True,
)
library_mbids = {(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id}
- albums = [self._transformers.lb_release_to_home(a, library_mbids) for a in lb_albums]
+ monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
+ albums = [self._transformers.lb_release_to_home(a, library_mbids, monitored_mbids) for a in lb_albums]
has_more = len(albums) > limit
items = albums[:limit]
return PopularAlbumsRangeResponse(
@@ -337,7 +347,7 @@ class HomeChartsService:
)
async def _get_trending_artists_lastfm(self, limit: int = 10) -> TrendingArtistsResponse:
- library_artists = await self._lidarr_repo.get_artists_from_library()
+ library_artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
lfm_artists = await self._lfm_repo.get_global_top_artists(limit=limit + 1)
artists = [
@@ -366,10 +376,15 @@ class HomeChartsService:
async def _get_popular_albums_lastfm(self, limit: int = 10) -> PopularAlbumsResponse:
ranges = ["this_week", "this_month", "this_year", "all_time"]
- library_albums = await self._lidarr_repo.get_library()
+ library_albums, monitored_mbids_result = await asyncio.gather(
+ self._lidarr_repo.get_library(include_unmonitored=True),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
+ return_exceptions=True,
+ )
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
+ monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
lfm_username = self._get_lastfm_username()
if lfm_username:
tasks = {
@@ -408,6 +423,7 @@ class HomeChartsService:
image_url=album.image_url or None,
listen_count=album.playcount,
in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False,
+ monitored=not ((album.mbid or "").lower() in library_mbids) and (album.mbid or "").lower() in monitored_mbids if album.mbid else False,
source="lastfm",
)
for album in lfm_albums
@@ -433,7 +449,7 @@ class HomeChartsService:
total_to_fetch = min(limit + offset + 1, 200)
lfm_artists, library_artists = await asyncio.gather(
self._lfm_repo.get_global_top_artists(limit=total_to_fetch),
- self._lidarr_repo.get_artists_from_library(),
+ self._lidarr_repo.get_artists_from_library(include_unmonitored=True),
)
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
artists = [
@@ -470,17 +486,20 @@ class HomeChartsService:
)
total_to_fetch = min(limit + offset + 1, 200)
- lfm_albums, library_albums = await asyncio.gather(
+ lfm_albums, library_albums, monitored_mbids_result = await asyncio.gather(
self._lfm_repo.get_user_top_albums(
lfm_username,
period=self._lastfm_period_for_range(range_key),
limit=total_to_fetch,
),
- self._lidarr_repo.get_library(),
+ self._lidarr_repo.get_library(include_unmonitored=True),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
+ return_exceptions=True,
)
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
+ monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
albums = [
HomeAlbum(
mbid=album.mbid,
@@ -490,6 +509,7 @@ class HomeChartsService:
image_url=album.image_url or None,
listen_count=album.playcount,
in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False,
+ monitored=not ((album.mbid or "").lower() in library_mbids) and (album.mbid or "").lower() in monitored_mbids if album.mbid else False,
source="lastfm",
)
for album in lfm_albums
@@ -521,10 +541,15 @@ class HomeChartsService:
this_week=empty, this_month=empty, this_year=empty, all_time=empty
)
- library_albums = await self._lidarr_repo.get_library()
+ library_albums, monitored_mbids_result = await asyncio.gather(
+ self._lidarr_repo.get_library(include_unmonitored=True),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
+ return_exceptions=True,
+ )
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
+ monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
ranges = ["this_week", "this_month", "this_year", "all_time"]
tasks = {
r: self._lb_repo.get_user_top_release_groups(
@@ -536,7 +561,7 @@ class HomeChartsService:
response_data: dict[str, PopularTimeRange] = {}
for r in ranges:
rgs = results.get(r) or []
- albums = [self._transformers.lb_release_to_home(rg, library_mbids) for rg in rgs]
+ albums = [self._transformers.lb_release_to_home(rg, library_mbids, monitored_mbids) for rg in rgs]
response_data[r] = PopularTimeRange(
range_key=r,
label=HomeDataTransformers.get_range_label(r),
@@ -578,16 +603,19 @@ class HomeChartsService:
has_more=False,
)
- library_albums, rgs = await asyncio.gather(
- self._lidarr_repo.get_library(),
+ library_albums, rgs, monitored_mbids_result = await asyncio.gather(
+ self._lidarr_repo.get_library(include_unmonitored=True),
self._lb_repo.get_user_top_release_groups(
username=lb_username, range_=range_key, count=limit + 1, offset=offset
),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
+ return_exceptions=True,
)
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
- albums = [self._transformers.lb_release_to_home(rg, library_mbids) for rg in rgs]
+ monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
+ albums = [self._transformers.lb_release_to_home(rg, library_mbids, monitored_mbids) for rg in rgs]
has_more = len(albums) > limit
items = albums[:limit]
return PopularAlbumsRangeResponse(
diff --git a/backend/services/home/facade.py b/backend/services/home/facade.py
index ef538ff..26eb23f 100644
--- a/backend/services/home/facade.py
+++ b/backend/services/home/facade.py
@@ -162,9 +162,10 @@ class HomeService:
)
if lidarr_configured:
- tasks["library_albums"] = self._lidarr_repo.get_library()
- tasks["library_artists"] = self._lidarr_repo.get_artists_from_library()
+ tasks["library_albums"] = self._lidarr_repo.get_library(include_unmonitored=True)
+ tasks["library_artists"] = self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
tasks["recently_imported"] = self._lidarr_repo.get_recently_imported(limit=15)
+ tasks["monitored_mbids"] = self._lidarr_repo.get_monitored_no_files_mbids()
if resolved_source == "listenbrainz" and lb_enabled and username:
lb_settings = self._preferences.get_listenbrainz_connection()
@@ -195,6 +196,7 @@ class HomeService:
library_album_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
+ monitored_mbids: set[str] = results.get("monitored_mbids") or set()
response = HomeResponse(integration_status=integration_status)
@@ -207,10 +209,10 @@ class HomeService:
results, library_artist_mbids
)
response.popular_albums = self._builders.build_popular_albums_section(
- results, library_album_mbids
+ results, library_album_mbids, monitored_mbids
)
response.your_top_albums = self._builders.build_lb_user_top_albums_section(
- results, library_album_mbids
+ results, library_album_mbids, monitored_mbids
)
response.recently_played = self._builders.build_listenbrainz_recent_section(results)
response.favorite_artists = self._builders.build_listenbrainz_favorites_section(results)
@@ -220,7 +222,7 @@ class HomeService:
results, library_artist_mbids
)
response.your_top_albums = self._builders.build_lastfm_top_albums_section(
- results, library_album_mbids
+ results, library_album_mbids, monitored_mbids
)
response.recently_played = self._builders.build_lastfm_recent_section(results)
response.favorite_artists = self._builders.build_lastfm_favorites_section(results)
diff --git a/backend/services/home/section_builders.py b/backend/services/home/section_builders.py
index 835c8ee..f14d0b1 100644
--- a/backend/services/home/section_builders.py
+++ b/backend/services/home/section_builders.py
@@ -60,24 +60,24 @@ class HomeSectionBuilders:
)
def build_popular_albums_section(
- self, results: dict[str, Any], library_mbids: set[str]
+ self, results: dict[str, Any], library_mbids: set[str], monitored_mbids: set[str] | None = None
) -> HomeSection:
albums = results.get("lb_trending_albums") or []
return HomeSection(
title="Popular Right Now",
type="albums",
- items=[self._transformers.lb_release_to_home(a, library_mbids) for a in albums[:15]],
+ items=[self._transformers.lb_release_to_home(a, library_mbids, monitored_mbids) for a in albums[:15]],
source="listenbrainz" if albums else None,
)
def build_lb_user_top_albums_section(
- self, results: dict[str, Any], library_mbids: set[str]
+ self, results: dict[str, Any], library_mbids: set[str], monitored_mbids: set[str] | None = None
) -> HomeSection | None:
release_groups = results.get("lb_user_top_rgs") or []
if not release_groups:
return None
items = [
- self._transformers.lb_release_to_home(rg, library_mbids)
+ self._transformers.lb_release_to_home(rg, library_mbids, monitored_mbids)
for rg in release_groups[:15]
]
return HomeSection(
@@ -95,7 +95,8 @@ class HomeSectionBuilders:
return HomeSection(title="Browse by Genre", type="genres", items=genres, source=source)
def build_fresh_releases_section(
- self, results: dict[str, Any], library_mbids: set[str]
+ self, results: dict[str, Any], library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
releases = results.get("lb_fresh")
if not releases:
@@ -103,7 +104,7 @@ class HomeSectionBuilders:
return HomeSection(
title="New From Artists You Follow",
type="albums",
- items=[self._transformers.lb_release_to_home(r, library_mbids) for r in releases[:15]],
+ items=[self._transformers.lb_release_to_home(r, library_mbids, monitored_mbids) for r in releases[:15]],
source="listenbrainz",
)
@@ -170,12 +171,12 @@ class HomeSectionBuilders:
)
def build_lastfm_top_albums_section(
- self, results: dict[str, Any], library_mbids: set[str]
+ self, results: dict[str, Any], library_mbids: set[str], monitored_mbids: set[str] | None = None
) -> HomeSection:
albums = results.get("lfm_top_albums") or []
items = [
a for a in (
- self._transformers.lastfm_album_to_home(album, library_mbids)
+ self._transformers.lastfm_album_to_home(album, library_mbids, monitored_mbids)
for album in albums[:15]
)
if a is not None
diff --git a/backend/services/home_transformers.py b/backend/services/home_transformers.py
index 496be5e..3d23167 100644
--- a/backend/services/home_transformers.py
+++ b/backend/services/home_transformers.py
@@ -70,9 +70,15 @@ class HomeDataTransformers:
def lb_release_to_home(
self,
release: ListenBrainzReleaseGroup,
- library_mbids: set[str]
+ library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeAlbum:
artist_mbid = release.artist_mbids[0] if release.artist_mbids else None
+ mbid_lower = (release.release_group_mbid or "").lower()
+ in_library = mbid_lower in library_mbids
+ monitored = (
+ not in_library and bool(monitored_mbids) and mbid_lower in monitored_mbids
+ )
return HomeAlbum(
mbid=release.release_group_mbid,
name=release.release_group_name,
@@ -81,7 +87,8 @@ class HomeDataTransformers:
image_url=None,
release_date=None,
listen_count=release.listen_count,
- in_library=(release.release_group_mbid or "").lower() in library_mbids,
+ in_library=in_library,
+ monitored=monitored,
)
def jf_item_to_artist(
@@ -130,7 +137,13 @@ class HomeDataTransformers:
self,
album: LastFmAlbum,
library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeAlbum | None:
+ mbid_lower = album.mbid.lower() if album.mbid else ""
+ in_library = mbid_lower in library_mbids if mbid_lower else False
+ monitored = (
+ not in_library and bool(monitored_mbids) and mbid_lower in monitored_mbids
+ ) if mbid_lower else False
return HomeAlbum(
mbid=None,
name=album.name,
@@ -138,7 +151,8 @@ class HomeDataTransformers:
artist_mbid=None,
image_url=album.image_url or None,
listen_count=album.playcount,
- in_library=album.mbid.lower() in library_mbids if album.mbid else False,
+ in_library=in_library,
+ monitored=monitored,
source="lastfm",
)
@@ -159,14 +173,21 @@ class HomeDataTransformers:
self,
track: LastFmRecentTrack,
library_mbids: set[str],
+ monitored_mbids: set[str] | None = None,
) -> HomeAlbum | None:
+ mbid_lower = (track.album_mbid or "").lower()
+ in_library = mbid_lower in library_mbids if track.album_mbid else False
+ monitored = (
+ not in_library and bool(monitored_mbids) and bool(mbid_lower) and mbid_lower in monitored_mbids
+ )
return HomeAlbum(
mbid=track.album_mbid,
name=track.album_name or track.track_name,
artist_name=track.artist_name,
artist_mbid=track.artist_mbid,
image_url=track.image_url or None,
- in_library=track.album_mbid.lower() in library_mbids if track.album_mbid else False,
+ in_library=in_library,
+ monitored=monitored,
source="lastfm",
)
diff --git a/backend/services/library_service.py b/backend/services/library_service.py
index be4e62e..13300ce 100644
--- a/backend/services/library_service.py
+++ b/backend/services/library_service.py
@@ -167,6 +167,15 @@ class LibraryService:
except Exception as e: # noqa: BLE001
logger.error(f"Failed to fetch requested mbids: {e}")
raise ExternalServiceError(f"Failed to fetch requested mbids: {e}")
+
+ async def get_monitored_mbids(self) -> list[str]:
+ if not self._lidarr_repo.is_configured():
+ return []
+ try:
+ return list(await self._lidarr_repo.get_monitored_no_files_mbids())
+ except Exception as e: # noqa: BLE001
+ logger.error(f"Failed to fetch monitored mbids: {e}")
+ raise ExternalServiceError(f"Failed to fetch monitored mbids: {e}")
async def get_artists(self, limit: int | None = None) -> list[LibraryArtist]:
try:
@@ -348,8 +357,8 @@ class LibraryService:
sync_succeeded = False
try:
- albums = await self._lidarr_repo.get_library()
- artists = await self._lidarr_repo.get_artists_from_library()
+ albums = await self._lidarr_repo.get_library(include_unmonitored=True)
+ artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
albums_data = [
{
diff --git a/backend/services/precache/album_phase.py b/backend/services/precache/album_phase.py
index 7c3ab01..a2211a1 100644
--- a/backend/services/precache/album_phase.py
+++ b/backend/services/precache/album_phase.py
@@ -29,7 +29,7 @@ class AlbumPhase:
async def precache_album_data(
self,
release_group_ids: list[str],
- monitored_mbids: set[str],
+ library_mbids: set[str],
status_service: CacheStatusService,
library_album_mbids: dict[str, Any] = None,
offset: int = 0,
@@ -48,11 +48,11 @@ class AlbumPhase:
cached_info = await album_service._cache.get(cache_key)
if not cached_info:
await status_service.update_progress(index + 1, f"Fetching metadata for {rgid[:8]}...", processed_albums=offset + index + 1, generation=generation)
- await album_service.get_album_info(rgid, monitored_mbids=monitored_mbids)
+ await album_service.get_album_info(rgid, library_mbids=library_mbids)
metadata_fetched = True
else:
await status_service.update_progress(index + 1, f"Cached: {rgid[:8]}...", processed_albums=offset + index + 1, generation=generation)
- if rgid.lower() in monitored_mbids:
+ if rgid.lower() in library_mbids:
cache_filename = get_cache_filename(f"rg_{rgid}", "500")
file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin"
if not file_path.exists():
diff --git a/backend/services/precache/orchestrator.py b/backend/services/precache/orchestrator.py
index 8dae105..a32172f 100644
--- a/backend/services/precache/orchestrator.py
+++ b/backend/services/precache/orchestrator.py
@@ -210,12 +210,12 @@ class LibraryPrecacheService:
if status_service.is_cancelled():
return
- monitored_mbids: set[str] = set()
+ library_album_mbids_set: set[str] = set()
for a in albums:
mbid = getattr(a, 'musicbrainz_id', None) if hasattr(a, 'musicbrainz_id') else a.get('mbid') if isinstance(a, dict) else None
if not is_unknown_mbid(mbid):
- monitored_mbids.add(mbid.lower())
- deduped_release_groups = list(monitored_mbids)
+ library_album_mbids_set.add(mbid.lower())
+ deduped_release_groups = list(library_album_mbids_set)
if status_service.is_cancelled():
return
items_needing_metadata = []
@@ -234,7 +234,7 @@ class LibraryPrecacheService:
for rgid in deduped_release_groups:
if rgid in processed_albums:
continue
- if rgid.lower() in monitored_mbids:
+ if rgid.lower() in library_album_mbids_set:
cache_filename = get_cache_filename(f"rg_{rgid}", "500")
file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin"
cover_paths.append((rgid, file_path))
@@ -245,7 +245,7 @@ class LibraryPrecacheService:
already_cached = len(deduped_release_groups) - len(items_to_process) - len(processed_albums)
if items_to_process:
await status_service.update_phase('albums', len(items_to_process), generation=generation)
- await self._album_phase.precache_album_data(items_to_process, monitored_mbids, status_service, library_album_mbids, len(processed_albums), generation=generation)
+ await self._album_phase.precache_album_data(items_to_process, library_album_mbids_set, status_service, library_album_mbids, len(processed_albums), generation=generation)
else:
await status_service.skip_phase('albums', generation=generation)
diff --git a/backend/services/search_service.py b/backend/services/search_service.py
index 5a30f64..abeb7e6 100644
--- a/backend/services/search_service.py
+++ b/backend/services/search_service.py
@@ -164,7 +164,7 @@ class SearchService:
limits["albums"] = limit_albums
try:
- grouped, library_mbids_raw, queue_items_raw = await self._safe_gather(
+ grouped, library_mbids_raw, queue_items_raw, monitored_mbids_raw = await self._safe_gather(
self._mb_repo.search_grouped(
query,
limits=limits,
@@ -173,10 +173,11 @@ class SearchService:
),
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
)
except Exception as e: # noqa: BLE001
logger.error(f"Search gather failed unexpectedly: {e}")
- grouped, library_mbids_raw, queue_items_raw = None, None, None
+ grouped, library_mbids_raw, queue_items_raw, monitored_mbids_raw = None, None, None, None
if grouped is None:
logger.warning("MusicBrainz search returned no results or failed")
@@ -188,10 +189,13 @@ class SearchService:
else:
queued_mbids = set()
+ monitored_mbids = monitored_mbids_raw or set()
+
for item in grouped.get("albums", []):
mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library
+ item.monitored = mbid_lower in monitored_mbids and not item.in_library and not item.requested
all_results = grouped.get("artists", []) + grouped.get("albums", [])
await self._apply_audiodb_search_overlay(all_results)
@@ -246,9 +250,10 @@ class SearchService:
return [], None
if bucket == "albums":
- library_mbids_raw, queue_items_raw = await self._safe_gather(
+ library_mbids_raw, queue_items_raw, monitored_mbids_raw = await self._safe_gather(
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
)
library_mbids = library_mbids_raw or set()
if queue_items_raw:
@@ -256,10 +261,13 @@ class SearchService:
else:
queued_mbids = set()
+ monitored_mbids = monitored_mbids_raw or set()
+
for item in results:
mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library
+ item.monitored = mbid_lower in monitored_mbids and not item.in_library and not item.requested
await self._apply_audiodb_search_overlay(results)
@@ -288,9 +296,10 @@ class SearchService:
grouped = grouped or {"artists": [], "albums": []}
- library_mbids_raw, queue_items_raw = await self._safe_gather(
+ library_mbids_raw, queue_items_raw, monitored_mbids_raw = await self._safe_gather(
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(),
+ self._lidarr_repo.get_monitored_no_files_mbids(),
)
library_mbids = library_mbids_raw or set()
if queue_items_raw:
@@ -298,10 +307,13 @@ class SearchService:
else:
queued_mbids = set()
+ monitored_mbids = monitored_mbids_raw or set()
+
for item in grouped.get("albums", []):
mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library
+ item.monitored = mbid_lower in monitored_mbids and not item.in_library and not item.requested
suggestions: list[SuggestResult] = []
for item in grouped.get("artists", []) + grouped.get("albums", []):
@@ -313,6 +325,7 @@ class SearchService:
musicbrainz_id=item.musicbrainz_id,
in_library=item.in_library,
requested=item.requested,
+ monitored=item.monitored,
disambiguation=item.disambiguation,
score=item.score,
))
diff --git a/backend/tests/infrastructure/test_disk_metadata_cache.py b/backend/tests/infrastructure/test_disk_metadata_cache.py
index 39f1b86..01697ca 100644
--- a/backend/tests/infrastructure/test_disk_metadata_cache.py
+++ b/backend/tests/infrastructure/test_disk_metadata_cache.py
@@ -8,6 +8,10 @@ from infrastructure.cache.disk_cache import DiskMetadataCache
from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages
+def _cache_hash(identifier: str) -> str:
+ return hashlib.sha1(f"{DiskMetadataCache._CACHE_VERSION}:{identifier}".encode()).hexdigest()
+
+
@pytest.mark.asyncio
async def test_set_album_serializes_msgspec_struct_as_mapping(tmp_path):
cache = DiskMetadataCache(base_path=tmp_path)
@@ -22,7 +26,7 @@ async def test_set_album_serializes_msgspec_struct_as_mapping(tmp_path):
await cache.set_album(mbid, album_info, is_monitored=True)
- cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
+ cache_hash = _cache_hash(mbid)
cache_file = tmp_path / "persistent" / "albums" / f"{cache_hash}.json"
payload = json.loads(cache_file.read_text())
@@ -39,7 +43,7 @@ async def test_get_album_deletes_corrupt_string_payload(tmp_path):
cache = DiskMetadataCache(base_path=tmp_path)
mbid = "8e1e9e51-38dc-4df3-8027-a0ada37d4674"
- cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
+ cache_hash = _cache_hash(mbid)
cache_file = tmp_path / "persistent" / "albums" / f"{cache_hash}.json"
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps("AlbumInfo(title='Corrupt')"))
@@ -69,7 +73,7 @@ async def test_audiodb_artist_entity_routing(tmp_path):
assert result["fanart_url"] == "https://example.com/fanart.jpg"
assert result["lookup_source"] == "mbid"
- cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
+ cache_hash = _cache_hash(mbid)
data_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json"
assert data_file.exists()
@@ -93,7 +97,7 @@ async def test_audiodb_album_entity_routing(tmp_path):
assert result["album_back_url"] == "https://example.com/album_back.jpg"
assert result["lookup_source"] == "name"
- cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
+ cache_hash = _cache_hash(mbid)
persistent_file = tmp_path / "persistent" / "audiodb_albums" / f"{cache_hash}.json"
assert persistent_file.exists()
@@ -160,7 +164,7 @@ async def test_audiodb_monitored_persistent_vs_recent(tmp_path):
await cache._set_entity("audiodb_artist", mbid, images, is_monitored=True, ttl_seconds=None)
- cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
+ cache_hash = _cache_hash(mbid)
persistent_file = tmp_path / "persistent" / "audiodb_artists" / f"{cache_hash}.json"
recent_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json"
assert persistent_file.exists()
diff --git a/backend/tests/repositories/test_lidarr_library_cache.py b/backend/tests/repositories/test_lidarr_library_cache.py
index acbcf1a..b79f0f2 100644
--- a/backend/tests/repositories/test_lidarr_library_cache.py
+++ b/backend/tests/repositories/test_lidarr_library_cache.py
@@ -26,6 +26,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2023-01-15",
"added": "2023-01-10T12:00:00Z",
"images": [],
+ "statistics": {"trackFileCount": 5},
"artist": {
"artistName": "Artist A",
"foreignArtistId": "artist-a-mbid",
@@ -39,6 +40,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2024-06-01",
"added": "2024-06-01T08:00:00Z",
"images": [],
+ "statistics": {"trackFileCount": 8},
"artist": {
"artistName": "Artist B",
"foreignArtistId": "artist-b-mbid",
@@ -52,6 +54,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2020-03-01",
"added": "2020-03-01T00:00:00Z",
"images": [],
+ "statistics": {"trackFileCount": 3},
"artist": {
"artistName": "Artist C",
"foreignArtistId": "artist-c-mbid",
diff --git a/backend/tests/services/test_album_service.py b/backend/tests/services/test_album_service.py
index 28204b5..e365a39 100644
--- a/backend/tests/services/test_album_service.py
+++ b/backend/tests/services/test_album_service.py
@@ -93,7 +93,7 @@ async def test_get_album_basic_info_does_not_use_library_cache_when_lidarr_paylo
result = await service.get_album_basic_info("8e1e9e51-38dc-4df3-8027-a0ada37d4674")
- assert result.in_library is False
+ assert result.in_library is True
library_db.get_album_by_mbid.assert_not_awaited()
@@ -102,7 +102,7 @@ async def test_get_album_tracks_info_preserves_disc_numbers_from_lidarr():
service, lidarr_repo, _ = _make_service()
service._get_cached_album_info = AsyncMock(return_value=None)
lidarr_repo.is_configured.return_value = True
- lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True})
+ lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True, "statistics": {"trackFileCount": 1}})
lidarr_repo.get_album_tracks = AsyncMock(
return_value=[
{
@@ -135,7 +135,7 @@ async def test_get_album_tracks_info_multi_disc_same_track_numbers():
service, lidarr_repo, _ = _make_service()
service._get_cached_album_info = AsyncMock(return_value=None)
lidarr_repo.is_configured.return_value = True
- lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True})
+ lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True, "statistics": {"trackFileCount": 1}})
lidarr_repo.get_album_tracks = AsyncMock(
return_value=[
{"track_number": 1, "disc_number": 1, "title": "Intro", "duration_ms": 1000},
diff --git a/backend/tests/services/test_artist_basic_info.py b/backend/tests/services/test_artist_basic_info.py
index 83f3455..781ba3a 100644
--- a/backend/tests/services/test_artist_basic_info.py
+++ b/backend/tests/services/test_artist_basic_info.py
@@ -42,6 +42,7 @@ def _make_service(*, cached_artist: ArtistInfo | None = None) -> tuple[ArtistSer
lidarr_repo.is_configured.return_value = False
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
+ lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
lidarr_repo.get_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock()
diff --git a/backend/tests/services/test_artist_release_pagination.py b/backend/tests/services/test_artist_release_pagination.py
index c45f7be..2ada2de 100644
--- a/backend/tests/services/test_artist_release_pagination.py
+++ b/backend/tests/services/test_artist_release_pagination.py
@@ -53,6 +53,7 @@ def _make_service(
lidarr_repo.get_artist_details = AsyncMock(return_value=lidarr_artist)
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
+ lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
lidarr_repo.get_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock()
diff --git a/backend/tests/services/test_audiodb_url_only_integration.py b/backend/tests/services/test_audiodb_url_only_integration.py
index 8df8d82..0301a66 100644
--- a/backend/tests/services/test_audiodb_url_only_integration.py
+++ b/backend/tests/services/test_audiodb_url_only_integration.py
@@ -52,6 +52,7 @@ def _search_service(audiodb: MagicMock | None = None) -> SearchService:
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
+ lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[])
diff --git a/backend/tests/services/test_search_audiodb_overlay.py b/backend/tests/services/test_search_audiodb_overlay.py
index c614b4b..6aedbed 100644
--- a/backend/tests/services/test_search_audiodb_overlay.py
+++ b/backend/tests/services/test_search_audiodb_overlay.py
@@ -56,6 +56,7 @@ def _search_service(audiodb=None) -> SearchService:
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
+ lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[])
diff --git a/backend/tests/services/test_search_service.py b/backend/tests/services/test_search_service.py
index 7b68724..d2d3c8d 100644
--- a/backend/tests/services/test_search_service.py
+++ b/backend/tests/services/test_search_service.py
@@ -59,6 +59,8 @@ def _make_service(
else:
lidarr_repo.get_queue = AsyncMock(return_value=queue_items or [])
+ lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
+
coverart_repo = MagicMock()
preferences_service = MagicMock()
preferences_service.get_preferences.return_value = _make_preferences()
@@ -300,6 +302,7 @@ async def test_suggest_deduplication_single_mb_call():
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
+ lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
preferences_service = MagicMock()
diff --git a/backend/tests/test_audiodb_killswitch.py b/backend/tests/test_audiodb_killswitch.py
index 194f31c..760298c 100644
--- a/backend/tests/test_audiodb_killswitch.py
+++ b/backend/tests/test_audiodb_killswitch.py
@@ -156,6 +156,7 @@ def _make_search_service(audiodb_service=None) -> SearchService:
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
+ lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[])
diff --git a/backend/tests/test_sync_coordinator.py b/backend/tests/test_sync_coordinator.py
index af2b06f..092d63a 100644
--- a/backend/tests/test_sync_coordinator.py
+++ b/backend/tests/test_sync_coordinator.py
@@ -66,7 +66,7 @@ class TestCooldownOnlyOnSuccess:
async def test_retry_after_failed_sync_is_not_cooldown_blocked(self):
call_count = 0
- async def fail_then_succeed():
+ async def fail_then_succeed(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
@@ -94,7 +94,7 @@ class TestSyncFutureDedup:
call_count = 0
sync_event = asyncio.Event()
- async def slow_get_library():
+ async def slow_get_library(**kwargs):
nonlocal call_count
call_count += 1
sync_event.set()
@@ -118,7 +118,7 @@ class TestSyncFutureDedup:
@pytest.mark.asyncio
async def test_concurrent_sync_failure_propagates_to_waiter(self):
"""When the producer fails, deduped waiters get the real exception."""
- async def failing_get_library():
+ async def failing_get_library(**kwargs):
await asyncio.sleep(0.05)
raise RuntimeError("Lidarr DNS failure")
diff --git a/frontend/src/lib/components/AlbumCard.svelte b/frontend/src/lib/components/AlbumCard.svelte
index 03fe161..541c988 100644
--- a/frontend/src/lib/components/AlbumCard.svelte
+++ b/frontend/src/lib/components/AlbumCard.svelte
@@ -5,6 +5,8 @@
import { libraryStore } from '$lib/stores/library';
import { integrationStore } from '$lib/stores/integration';
import { requestAlbum } from '$lib/utils/albumRequest';
+ import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
+ import { toastStore } from '$lib/stores/toast';
import { formatListenCount } from '$lib/utils/formatting';
import { getListenTitle } from '$lib/utils/enrichment';
import { Download, Music2 } from 'lucide-svelte';
@@ -29,6 +31,7 @@
let listenTitle = $derived(getListenTitle(enrichmentSource, 'album'));
let requesting = $state(false);
+ let monitoredLoading = $state(false);
let inLibrary = $derived(
libraryStore.isInLibrary(album.musicbrainz_id) || album.in_library || false
@@ -38,6 +41,11 @@
!album.in_library &&
(album.requested || libraryStore.isRequested(album.musicbrainz_id))
);
+ let isMonitored = $derived(
+ !inLibrary &&
+ !isRequested &&
+ (album.monitored || libraryStore.isMonitored(album.musicbrainz_id))
+ );
async function handleRequest(e: Event) {
e.stopPropagation();
@@ -56,9 +64,23 @@
}
}
+ async function handleToggleMonitored() {
+ monitoredLoading = true;
+ try {
+ await toggleAlbumMonitored(album.musicbrainz_id, false);
+ album.monitored = false;
+ album = album;
+ } catch {
+ toastStore.show({ message: 'Failed to update monitoring status', type: 'error' });
+ } finally {
+ monitoredLoading = false;
+ }
+ }
+
function handleDeleted() {
album.in_library = false;
album.requested = false;
+ album.monitored = false;
album = album;
onremoved?.();
}
@@ -161,6 +183,15 @@
artistName={album.artist || 'Unknown'}
ondeleted={handleDeleted}
/>
+ {:else if isMonitored}
+