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} + {/if} {/if} diff --git a/frontend/src/lib/components/DiscoveryAlbumCarousel.svelte b/frontend/src/lib/components/DiscoveryAlbumCarousel.svelte index 20feb2e..e5c59a0 100644 --- a/frontend/src/lib/components/DiscoveryAlbumCarousel.svelte +++ b/frontend/src/lib/components/DiscoveryAlbumCarousel.svelte @@ -28,6 +28,7 @@ musicbrainz_id: da.musicbrainz_id, in_library: da.in_library, requested: da.requested, + monitored: da.monitored, cover_url: da.cover_url }; } diff --git a/frontend/src/lib/components/GenreAlbumCard.svelte b/frontend/src/lib/components/GenreAlbumCard.svelte index 614df93..ad0d247 100644 --- a/frontend/src/lib/components/GenreAlbumCard.svelte +++ b/frontend/src/lib/components/GenreAlbumCard.svelte @@ -1,5 +1,5 @@ +{#snippet requestButton(rg: Release, ariaLabel: string)} + +{/snippet} +
+ {@render requestButton(rg, `Request ${title.toLowerCase().slice(0, -1)}`)} {/if}
diff --git a/frontend/src/lib/components/SearchSuggestions.svelte b/frontend/src/lib/components/SearchSuggestions.svelte index 5289949..69f6c35 100644 --- a/frontend/src/lib/components/SearchSuggestions.svelte +++ b/frontend/src/lib/components/SearchSuggestions.svelte @@ -271,6 +271,9 @@ {#if result.requested} Requested {/if} + {#if result.monitored && !result.in_library && !result.requested} + Monitored + {/if} {/each} diff --git a/frontend/src/lib/components/TimeRangeCard.svelte b/frontend/src/lib/components/TimeRangeCard.svelte index e7b7d2c..2e65352 100644 --- a/frontend/src/lib/components/TimeRangeCard.svelte +++ b/frontend/src/lib/components/TimeRangeCard.svelte @@ -1,5 +1,5 @@
@@ -72,7 +82,7 @@ />
- {#if (inLibrary || isRequested) && lidarrConfigured} + {#if (inLibrary || isRequested || albumMonitored) && lidarrConfigured}
diff --git a/frontend/src/routes/album/[id]/albumEventHandlers.ts b/frontend/src/routes/album/[id]/albumEventHandlers.ts index 7e7b1a2..3f773a2 100644 --- a/frontend/src/routes/album/[id]/albumEventHandlers.ts +++ b/frontend/src/routes/album/[id]/albumEventHandlers.ts @@ -3,6 +3,7 @@ import { artistHref } from '$lib/utils/entityRoutes'; import type { AlbumBasicInfo, YouTubeTrackLink, YouTubeLink, YouTubeQuotaStatus } from '$lib/types'; import { compareDiscTrack, getDiscTrackKey } from '$lib/player/queueHelpers'; import { requestAlbum } from '$lib/utils/albumRequest'; +import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum'; export interface EventHandlerDeps { getAlbum: () => AlbumBasicInfo | null; @@ -18,6 +19,7 @@ export interface EventHandlerDeps { setShowDeleteModal: (v: boolean) => void; setShowArtistRemovedModal: (v: boolean) => void; setRemovedArtistName: (v: string) => void; + setMonitorToggleLoading: (v: boolean) => void; setToast: (msg: string, type: 'success' | 'error' | 'info' | 'warning') => void; setShowToast: (v: boolean) => void; onRequestSuccess?: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void; @@ -85,6 +87,7 @@ export function createEventHandlers(deps: EventHandlerDeps) { if (album) { album.in_library = false; album.requested = false; + album.monitored = false; deps.setAlbum(album); deps.albumBasicCacheSet(album, deps.getAlbumId()); } @@ -96,6 +99,26 @@ export function createEventHandlers(deps: EventHandlerDeps) { } } + async function handleToggleMonitored(monitored: boolean): Promise { + const album = deps.getAlbum(); + if (!album) return; + deps.setMonitorToggleLoading(true); + try { + await toggleAlbumMonitored(album.musicbrainz_id, monitored); + const current = deps.getAlbum(); + if (current) { + current.monitored = monitored; + deps.setAlbum(current); + deps.albumBasicCacheSet(current, deps.getAlbumId()); + } + } catch { + deps.setToast('Failed to update monitoring status', 'error'); + deps.setShowToast(true); + } finally { + deps.setMonitorToggleLoading(false); + } + } + function goToArtist(): void { const album = deps.getAlbum(); // eslint-disable-next-line svelte/no-navigation-without-resolve -- artistHref uses resolve() internally @@ -110,6 +133,7 @@ export function createEventHandlers(deps: EventHandlerDeps) { handleRequest, handleDeleteClick, handleDeleted, + handleToggleMonitored, goToArtist }; } diff --git a/frontend/src/routes/album/[id]/albumPageState.svelte.ts b/frontend/src/routes/album/[id]/albumPageState.svelte.ts index e8c62f2..209aa00 100644 --- a/frontend/src/routes/album/[id]/albumPageState.svelte.ts +++ b/frontend/src/routes/album/[id]/albumPageState.svelte.ts @@ -108,6 +108,7 @@ export function createAlbumPageState(albumIdGetter: () => string) { let playlistModalRef = $state<{ open: (tracks: QueueItem[]) => void } | null>(null); let abortController: AbortController | null = null; let refreshing = $state(false); + let monitorToggleLoading = $state(false); let pollingForSources = $state(false); let pollTimer: ReturnType | null = null; let artistInLidarr = $state(false); @@ -130,6 +131,17 @@ export function createAlbumPageState(albumIdGetter: () => string) { const isRequested = $derived( !!(album && !inLibrary && (album.requested || libraryStore.isRequested(album.musicbrainz_id))) ); + const isMonitored = $derived( + !!( + album && + !inLibrary && + !isRequested && + (album.monitored || libraryStore.isMonitored(album.musicbrainz_id)) + ) + ); + const albumMonitored = $derived( + !!(album && (album.monitored || libraryStore.isMonitored(album.musicbrainz_id))) + ); function resetState() { if (abortController) { @@ -562,6 +574,7 @@ export function createAlbumPageState(albumIdGetter: () => string) { setShowDeleteModal: (v) => (showDeleteModal = v), setShowArtistRemovedModal: (v) => (showArtistRemovedModal = v), setRemovedArtistName: (v) => (removedArtistName = v), + setMonitorToggleLoading: (v) => (monitorToggleLoading = v), setToast: (msg, type) => { toastMessage = msg; toastType = type; @@ -800,6 +813,12 @@ export function createAlbumPageState(albumIdGetter: () => string) { get isRequested() { return isRequested; }, + get isMonitored() { + return isMonitored; + }, + get albumMonitored() { + return albumMonitored; + }, get artistInLidarr() { return artistInLidarr; }, @@ -809,6 +828,9 @@ export function createAlbumPageState(albumIdGetter: () => string) { get refreshing() { return refreshing; }, + get monitorToggleLoading() { + return monitorToggleLoading; + }, get pollingForSources() { return pollingForSources; },