In library rework + Monitored/Unmonitored statuses (#50)

* In library rework + Monitored/Unmonitored statuses

* address comments + format
This commit is contained in:
Harvey
2026-04-16 00:51:13 +01:00
committed by GitHub
parent 6ca23bc725
commit d24e26fb32
59 changed files with 772 additions and 187 deletions
+43 -2
View File
@@ -1,4 +1,4 @@
from typing import Optional import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
from core.exceptions import ClientDisconnectedError from core.exceptions import ClientDisconnectedError
@@ -11,9 +11,12 @@ from services.album_enrichment_service import AlbumEnrichmentService
from services.navidrome_library_service import NavidromeLibraryService from services.navidrome_library_service import NavidromeLibraryService
from infrastructure.validators import is_unknown_mbid from infrastructure.validators import is_unknown_mbid
from infrastructure.degradation import try_get_degradation_context 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.structs
import msgspec
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/albums", tags=["album"]) 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) 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) @router.get("/{album_id}/lastfm", response_model=LastFmAlbumEnrichment)
async def get_album_lastfm_enrichment( async def get_album_lastfm_enrichment(
album_id: str, album_id: str,
+3 -2
View File
@@ -112,11 +112,12 @@ async def get_library_stats(
async def get_library_mbids( async def get_library_mbids(
library_service: LibraryService = Depends(get_library_service) 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_library_mbids(),
library_service.get_requested_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) @router.get("/grouped", response_model=LibraryGroupedResponse)
+1
View File
@@ -16,6 +16,7 @@ class AlbumBasicInfo(AppStruct):
disambiguation: str | None = None disambiguation: str | None = None
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
cover_url: str | None = None cover_url: str | None = None
album_thumb_url: str | None = None album_thumb_url: str | None = None
+1
View File
@@ -24,6 +24,7 @@ class DiscoverQueueItemLight(AppStruct):
cover_url: str | None = None cover_url: str | None = None
is_wildcard: bool = False is_wildcard: bool = False
in_library: bool = False in_library: bool = False
monitored: bool = False
class DiscoverQueueEnrichment(AppStruct): class DiscoverQueueEnrichment(AppStruct):
+2
View File
@@ -41,6 +41,7 @@ class TopAlbum(AppStruct):
listen_count: int = 0 listen_count: int = 0
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
cover_url: str | None = None cover_url: str | None = None
@@ -58,6 +59,7 @@ class DiscoveryAlbum(AppStruct):
year: int | None = None year: int | None = None
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
cover_url: str | None = None cover_url: str | None = None
+1
View File
@@ -22,6 +22,7 @@ class HomeAlbum(AppStruct):
listen_count: int | None = None listen_count: int | None = None
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
source: str | None = None source: str | None = None
+1
View File
@@ -73,6 +73,7 @@ class SyncLibraryResponse(AppStruct):
class LibraryMbidsResponse(AppStruct): class LibraryMbidsResponse(AppStruct):
mbids: list[str] = [] mbids: list[str] = []
requested_mbids: list[str] = [] requested_mbids: list[str] = []
monitored_mbids: list[str] = []
class LibraryGroupedResponse(AppStruct): class LibraryGroupedResponse(AppStruct):
+1
View File
@@ -64,6 +64,7 @@ class SuggestResult(AppStruct):
year: int | None = None year: int | None = None
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
disambiguation: str | None = None disambiguation: str | None = None
score: int = 0 score: int = 0
+4
View File
@@ -154,6 +154,10 @@ def lidarr_requested_mbids_key() -> str:
return f"{LIDARR_REQUESTED_PREFIX}_mbids" return f"{LIDARR_REQUESTED_PREFIX}_mbids"
def lidarr_monitored_mbids_key() -> str:
return f"{LIDARR_PREFIX}monitored_mbids"
def lidarr_status_key() -> str: def lidarr_status_key() -> str:
return f"{LIDARR_PREFIX}status" return f"{LIDARR_PREFIX}status"
+3 -1
View File
@@ -18,6 +18,8 @@ def _decode_json(text: str) -> Any:
class DiskMetadataCache: class DiskMetadataCache:
_CACHE_VERSION = "v3"
def __init__( def __init__(
self, self,
base_path: Path, base_path: Path,
@@ -57,7 +59,7 @@ class DiskMetadataCache:
@staticmethod @staticmethod
def _cache_hash(identifier: str) -> str: def _cache_hash(identifier: str) -> str:
return hashlib.sha1(identifier.encode()).hexdigest() return hashlib.sha1(f"{DiskMetadataCache._CACHE_VERSION}:{identifier}".encode()).hexdigest()
@staticmethod @staticmethod
def _meta_path(file_path: Path) -> Path: def _meta_path(file_path: Path) -> Path:
+1
View File
@@ -26,6 +26,7 @@ class AlbumInfo(AppStruct):
total_length: int | None = None total_length: int | None = None
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
cover_url: str | None = None cover_url: str | None = None
album_thumb_url: str | None = None album_thumb_url: str | None = None
album_back_url: str | None = None album_back_url: str | None = None
+1
View File
@@ -26,6 +26,7 @@ class ReleaseItem(AppStruct):
year: int | None = None year: int | None = None
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
class ArtistInfo(AppStruct): class ArtistInfo(AppStruct):
+1
View File
@@ -9,6 +9,7 @@ class SearchResult(AppStruct):
year: int | None = None year: int | None = None
in_library: bool = False in_library: bool = False
requested: bool = False requested: bool = False
monitored: bool = False
cover_url: str | None = None cover_url: str | None = None
album_thumb_url: str | None = None album_thumb_url: str | None = None
thumb_url: str | None = None thumb_url: str | None = None
+14
View File
@@ -296,6 +296,20 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
logger.error(f"Failed to delete album {album_id}: {e}") logger.error(f"Failed to delete album {album_id}: {e}")
raise 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: async def add_album(self, musicbrainz_id: str, artist_repo) -> dict:
t0 = time.monotonic() t0 = time.monotonic()
if not musicbrainz_id or not isinstance(musicbrainz_id, str): if not musicbrainz_id or not isinstance(musicbrainz_id, str):
+2 -1
View File
@@ -5,7 +5,7 @@ import time
from typing import Any, Optional from typing import Any, Optional
from core.config import Settings from core.config import Settings
from core.exceptions import ExternalServiceError 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.cache.memory_cache import CacheInterface
from infrastructure.http.deduplication import get_deduplicator from infrastructure.http.deduplication import get_deduplicator
from infrastructure.resilience.retry import with_retry, CircuitBreaker 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.delete(lidarr_raw_albums_key())
await self._cache.clear_prefix(f"{LIDARR_PREFIX}library:") await self._cache.clear_prefix(f"{LIDARR_PREFIX}library:")
await self._cache.delete(lidarr_requested_mbids_key()) 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: async def _post(self, endpoint: str, data: dict[str, Any]) -> Any:
return await self._request("POST", endpoint, json_data=data) return await self._request("POST", endpoint, json_data=data)
+21 -5
View File
@@ -9,6 +9,7 @@ from infrastructure.cache.cache_keys import (
lidarr_artist_mbids_key, lidarr_artist_mbids_key,
lidarr_library_grouped_key, lidarr_library_grouped_key,
lidarr_requested_mbids_key, lidarr_requested_mbids_key,
lidarr_monitored_mbids_key,
) )
from .base import LidarrBase from .base import LidarrBase
@@ -31,11 +32,17 @@ class LidarrLibraryRepository(LidarrBase):
for item in data: for item in data:
is_monitored = item.get("monitored", False) 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: if not is_monitored and not include_unmonitored:
filtered_count += 1 filtered_count += 1
continue continue
if not has_files:
filtered_count += 1
continue
artist_data = item.get("artist", {}) artist_data = item.get("artist", {})
artist = artist_data.get("artistName", "Unknown") artist = artist_data.get("artistName", "Unknown")
artist_mbid = artist_data.get("foreignArtistId") artist_mbid = artist_data.get("foreignArtistId")
@@ -92,11 +99,17 @@ class LidarrLibraryRepository(LidarrBase):
for item in albums_data: for item in albums_data:
is_monitored = item.get("monitored", False) 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: if not is_monitored and not include_unmonitored:
filtered_count += 1 filtered_count += 1
continue continue
if not has_files:
filtered_count += 1
continue
artist_data = item.get("artist", {}) artist_data = item.get("artist", {})
artist_mbid = artist_data.get("foreignArtistId") artist_mbid = artist_data.get("foreignArtistId")
artist_name = artist_data.get("artistName", "Unknown") artist_name = artist_data.get("artistName", "Unknown")
@@ -139,6 +152,10 @@ class LidarrLibraryRepository(LidarrBase):
grouped: dict[str, list[LibraryGroupedAlbum]] = {} grouped: dict[str, list[LibraryGroupedAlbum]] = {}
for item in data: for item in data:
statistics = item.get("statistics", {})
if statistics.get("trackFileCount", 0) == 0:
continue
artist = item.get("artist", {}).get("artistName", "Unknown") artist = item.get("artist", {}).get("artistName", "Unknown")
title = item.get("title") title = item.get("title")
year = None year = None
@@ -182,9 +199,6 @@ class LidarrLibraryRepository(LidarrBase):
data = await self._get_all_albums_raw() data = await self._get_all_albums_raw()
ids: set[str] = set() ids: set[str] = set()
for item in data: for item in data:
if not item.get("monitored", False):
continue
statistics = item.get("statistics", {}) statistics = item.get("statistics", {})
track_file_count = statistics.get("trackFileCount", 0) track_file_count = statistics.get("trackFileCount", 0)
if track_file_count == 0: if track_file_count == 0:
@@ -212,7 +226,9 @@ class LidarrLibraryRepository(LidarrBase):
data = await self._get("/api/v1/artist") data = await self._get("/api/v1/artist")
ids: set[str] = set() ids: set[str] = set()
for item in data: 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 continue
mbid = item.get("foreignArtistId") or item.get("mbId") mbid = item.get("foreignArtistId") or item.get("mbId")
if isinstance(mbid, str): if isinstance(mbid, str):
@@ -232,7 +248,7 @@ class LidarrLibraryRepository(LidarrBase):
async def get_monitored_no_files_mbids(self) -> set[str]: async def get_monitored_no_files_mbids(self) -> set[str]:
"""Return monitored Lidarr albums that have no downloaded track files.""" """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) cached_result = await self._cache.get(cache_key)
if cached_result is not None: if cached_result is not None:
+6
View File
@@ -49,6 +49,9 @@ class LidarrRepositoryProtocol(Protocol):
async def get_requested_mbids(self) -> set[str]: 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: 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", self, artist_mbid: str, *, monitored: bool, monitor_new_items: str = "none",
) -> dict[str, Any]: ) -> dict[str, Any]:
... ...
async def set_monitored(self, album_mbid: str, monitored: bool) -> bool:
...
+68 -16
View File
@@ -8,7 +8,7 @@ from repositories.protocols import LidarrRepositoryProtocol, MusicBrainzReposito
from services.preferences_service import PreferencesService 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 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.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.memory_cache import CacheInterface
from infrastructure.cache.disk_cache import DiskMetadataCache from infrastructure.cache.disk_cache import DiskMetadataCache
from infrastructure.cover_urls import prefer_release_group_cover_url 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) 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: def _check_lidarr_in_library(self, lidarr_album: dict | None) -> bool:
if lidarr_album and lidarr_album.get("monitored", False): if lidarr_album is None:
statistics = lidarr_album.get("statistics", {}) return False
return statistics.get("trackFileCount", 0) > 0 statistics = lidarr_album.get("statistics", {})
return False return statistics.get("trackFileCount", 0) > 0
async def warm_full_album_cache(self, release_group_id: str) -> None: async def warm_full_album_cache(self, release_group_id: str) -> None:
"""Fire-and-forget: populate the full album_info cache if missing.""" """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) 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: try:
release_group_id = validate_mbid(release_group_id, "album") release_group_id = validate_mbid(release_group_id, "album")
except ValueError as e: except ValueError as e:
@@ -215,7 +256,7 @@ class AlbumService:
future: asyncio.Future[AlbumInfo] = loop.create_future() future: asyncio.Future[AlbumInfo] = loop.create_future()
self._album_in_flight[release_group_id] = future self._album_in_flight[release_group_id] = future
try: 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(): if not future.done():
future.set_result(album_info) future.set_result(album_info)
return album_info return album_info
@@ -232,14 +273,16 @@ class AlbumService:
raise ResourceNotFoundError(f"Failed to get album info: {e}") raise ResourceNotFoundError(f"Failed to get album info: {e}")
async def _do_get_album_info( 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: ) -> AlbumInfo:
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None 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) in_library = self._check_lidarr_in_library(lidarr_album)
if in_library and lidarr_album: if in_library and lidarr_album:
album_info = await self._build_album_from_lidarr(release_group_id, lidarr_album) album_info = await self._build_album_from_lidarr(release_group_id, lidarr_album)
else: 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 = await self._apply_audiodb_album_images(
album_info, release_group_id, album_info.artist_name, album_info.title, album_info, release_group_id, album_info.artist_name, album_info.title,
allow_fetch=True, is_monitored=album_info.in_library, allow_fetch=True, is_monitored=album_info.in_library,
@@ -286,14 +329,21 @@ class AlbumService:
disambiguation=cached_album_info.disambiguation, disambiguation=cached_album_info.disambiguation,
in_library=cached_album_info.in_library, in_library=cached_album_info.in_library,
requested=is_requested and not 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, cover_url=cached_album_info.cover_url,
album_thumb_url=album_thumb, 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 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) 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 = 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: if not basic.album_thumb_url:
basic.album_thumb_url = await self._get_audiodb_album_thumb( basic.album_thumb_url = await self._get_audiodb_album_thumb(
release_group_id, basic.artist_name, basic.title, 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) cached_album = await self._library_db.get_album_by_mbid(release_group_id)
in_library = cached_album is not None in_library = cached_album is not None
basic = AlbumBasicInfo(**mb_to_basic_info(release_group, release_group_id, in_library, is_requested)) 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( basic.album_thumb_url = await self._get_audiodb_album_thumb(
release_group_id, basic.artist_name, basic.title, release_group_id, basic.artist_name, basic.title,
allow_fetch=False, 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 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: if in_library and lidarr_album:
album_id = lidarr_album.get("id") album_id = lidarr_album.get("id")
@@ -428,9 +479,9 @@ class AlbumService:
return rg_result return rg_result
async def _check_in_library(self, release_group_id: str, monitored_mbids: set[str] = None) -> bool: async def _check_in_library(self, release_group_id: str, library_mbids: set[str] = None) -> bool:
if monitored_mbids is not None: if library_mbids is not None:
return release_group_id.lower() in monitored_mbids return release_group_id.lower() in library_mbids
library_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True) library_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True)
return release_group_id.lower() in library_mbids return release_group_id.lower() in library_mbids
@@ -557,13 +608,14 @@ class AlbumService:
total_tracks=len(tracks), total_tracks=len(tracks),
total_length=total_length if total_length > 0 else None, total_length=total_length if total_length > 0 else None,
in_library=True, in_library=True,
monitored=lidarr_album.get("monitored", False),
cover_url=cover_url, cover_url=cover_url,
) )
async def _build_album_from_musicbrainz( async def _build_album_from_musicbrainz(
self, self,
release_group_id: str, release_group_id: str,
monitored_mbids: set[str] = None library_mbids: set[str] = None
) -> AlbumInfo: ) -> AlbumInfo:
cached_album = await self._library_db.get_album_by_mbid(release_group_id) cached_album = await self._library_db.get_album_by_mbid(release_group_id)
in_library = cached_album is not None in_library = cached_album is not None
@@ -573,7 +625,7 @@ class AlbumService:
artist_name, artist_id = extract_artist_info(release_group) artist_name, artist_id = extract_artist_info(release_group)
if not in_library: 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( basic_info = self._build_basic_info(
release_group, release_group_id, artist_name, artist_id, in_library release_group, release_group_id, artist_name, artist_id, in_library
+2
View File
@@ -129,6 +129,7 @@ def lidarr_to_basic_info(lidarr_album: dict, release_group_id: str, in_library:
"disambiguation": lidarr_album.get("disambiguation"), "disambiguation": lidarr_album.get("disambiguation"),
"in_library": in_library, "in_library": in_library,
"requested": is_requested and not in_library, "requested": is_requested and not in_library,
"monitored": lidarr_album.get("monitored", False),
"cover_url": lidarr_album.get("cover_url"), "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"), "disambiguation": release_group.get("disambiguation"),
"in_library": in_library, "in_library": in_library,
"requested": is_requested and not in_library, "requested": is_requested and not in_library,
"monitored": False,
"cover_url": None, "cover_url": None,
} }
+42 -20
View File
@@ -218,8 +218,15 @@ class ArtistService:
library_album_mbids: dict[str, Any] | None, library_album_mbids: dict[str, Any] | None,
) -> ArtistInfo: ) -> ArtistInfo:
lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id) if self._lidarr_repo.is_configured() else None 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) use_lidarr_data = False
if in_library and lidarr_artist: 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) artist_info = await self._build_artist_from_lidarr(artist_id, lidarr_artist, library_album_mbids)
else: else:
artist_info = await self._build_artist_from_musicbrainz(artist_id, library_artist_mbids, library_album_mbids) 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_extended: bool = True,
include_releases: bool = True, include_releases: bool = True,
) -> ArtistInfo: ) -> 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 artist_id, library_artist_mbids, library_album_mbids
) )
in_library = artist_id.lower() in library_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) description, image = (await self._fetch_wikidata_info(mb_artist)) if include_extended else (None, None)
info = build_base_artist_info( info = build_base_artist_info(
mb_artist, artist_id, in_library, mb_artist, artist_id, in_library,
@@ -440,9 +447,10 @@ class ArtistService:
if not self._lidarr_repo.is_configured(): if not self._lidarr_repo.is_configured():
return return
try: 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_library_mbids(include_release_ids=False),
self._lidarr_repo.get_requested_mbids(), self._lidarr_repo.get_requested_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
self._lidarr_repo.get_artist_mbids(), self._lidarr_repo.get_artist_mbids(),
) )
for release_list in (artist_info.albums, artist_info.singles, artist_info.eps): for release_list in (artist_info.albums, artist_info.singles, artist_info.eps):
@@ -452,6 +460,7 @@ class ArtistService:
continue continue
rg.in_library = rg_id in library_mbids rg.in_library = rg_id in library_mbids
rg.requested = rg_id in requested_mbids and not rg.in_library 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() mbid_lower = artist_info.musicbrainz_id.lower()
is_in_artist_mbids = mbid_lower in artist_mbids is_in_artist_mbids = mbid_lower in artist_mbids
artist_info.in_library = is_in_artist_mbids artist_info.in_library = is_in_artist_mbids
@@ -530,12 +539,20 @@ class ArtistService:
) -> ArtistReleases: ) -> ArtistReleases:
try: try:
lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id) 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_library_mbids(include_release_ids=True),
self._lidarr_repo.get_requested_mbids(), self._lidarr_repo.get_requested_mbids(),
self._get_library_cache_mbids(), self._get_library_cache_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
) )
album_mbids = album_mbids | cache_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_primary_types = set(t.lower() for t in prefs.primary_types)
included_secondary_types = set(t.lower() for t in prefs.secondary_types) included_secondary_types = set(t.lower() for t in prefs.secondary_types)
if in_library: if has_lidarr_data:
if offset == 0: if offset == 0:
lidarr_albums = await self._lidarr_repo.get_artist_albums(artist_id) 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) 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( return await self._filter_aware_release_page(
artist_id, offset, limit, album_mbids, requested_mbids, 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 except Exception as e: # noqa: BLE001
logger.error(f"Error fetching releases for artist {artist_id} at offset {offset}: {e}") logger.error(f"Error fetching releases for artist {artist_id} at offset {offset}: {e}")
@@ -586,6 +603,7 @@ class ArtistService:
requested_mbids: set[str], requested_mbids: set[str],
included_primary_types: set[str], included_primary_types: set[str],
included_secondary_types: set[str], included_secondary_types: set[str],
monitored_mbids: set[str] | None = None,
) -> ArtistReleases: ) -> ArtistReleases:
if not included_primary_types: if not included_primary_types:
return ArtistReleases( return ArtistReleases(
@@ -618,7 +636,7 @@ class ArtistService:
temp_artist = {"release-group-list": release_groups} temp_artist = {"release-group-list": release_groups}
page_albums, page_singles, page_eps = categorize_release_groups( page_albums, page_singles, page_eps = categorize_release_groups(
temp_artist, album_mbids, included_primary_types, temp_artist, album_mbids, included_primary_types,
included_secondary_types, requested_mbids, included_secondary_types, requested_mbids, monitored_mbids,
) )
for item in page_albums: for item in page_albums:
@@ -669,24 +687,27 @@ class ArtistService:
artist_id: str, artist_id: str,
library_artist_mbids: set[str] = None, library_artist_mbids: set[str] = None,
library_album_mbids: dict[str, Any] = 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: 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) mb_artist = await self._mb_repo.get_artist_by_id(artist_id)
library_mbids = library_artist_mbids library_mbids = library_artist_mbids
album_mbids = library_album_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_requested_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True, return_exceptions=True,
) )
requested_mbids = requested_result[0] if not isinstance(requested_result[0], BaseException) else set() requested_mbids = requested_result if not isinstance(requested_result, BaseException) else set()
if isinstance(requested_result[0], BaseException): monitored_mbids = monitored_result if not isinstance(monitored_result, BaseException) else set()
logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result[0]}") if isinstance(requested_result, BaseException):
logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result}")
else: else:
mb_artist, *lidarr_results = await asyncio.gather( mb_artist, *lidarr_results = await asyncio.gather(
self._mb_repo.get_artist_by_id(artist_id), self._mb_repo.get_artist_by_id(artist_id),
self._lidarr_repo.get_artist_mbids(), self._lidarr_repo.get_artist_mbids(),
self._lidarr_repo.get_library_mbids(include_release_ids=True), self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_requested_mbids(), self._lidarr_repo.get_requested_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True, return_exceptions=True,
) )
if isinstance(mb_artist, BaseException): 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() 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() 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() 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() cache_mbids = await self._get_library_cache_mbids()
album_mbids = album_mbids | cache_mbids album_mbids = album_mbids | cache_mbids
if not mb_artist: if not mb_artist:
raise ResourceNotFoundError("Artist not found") 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]: def _build_external_links(self, mb_artist: dict[str, Any]) -> list[ExternalLink]:
external_links_data = extract_external_links(mb_artist) external_links_data = extract_external_links(mb_artist)
@@ -720,7 +740,8 @@ class ArtistService:
self, self,
mb_artist: dict[str, Any], mb_artist: dict[str, Any],
album_mbids: set[str], 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]]: ) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]:
prefs = self._preferences_service.get_preferences() prefs = self._preferences_service.get_preferences()
included_primary_types = set(t.lower() for t in prefs.primary_types) included_primary_types = set(t.lower() for t in prefs.primary_types)
@@ -730,7 +751,8 @@ class ArtistService:
album_mbids, album_mbids,
included_primary_types, included_primary_types,
included_secondary_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]]: async def _fetch_wikidata_info(self, mb_artist: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
+7
View File
@@ -106,11 +106,14 @@ def categorize_release_groups(
included_primary_types: Optional[set[str]] = None, included_primary_types: Optional[set[str]] = None,
included_secondary_types: Optional[set[str]] = None, included_secondary_types: Optional[set[str]] = None,
requested_mbids: 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]]]: ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
if included_primary_types is None: if included_primary_types is None:
included_primary_types = {"album", "single", "ep", "broadcast", "other"} included_primary_types = {"album", "single", "ep", "broadcast", "other"}
if requested_mbids is None: if requested_mbids is None:
requested_mbids = set() requested_mbids = set()
if monitored_mbids is None:
monitored_mbids = set()
albums: list[ReleaseItem] = [] albums: list[ReleaseItem] = []
singles: list[ReleaseItem] = [] singles: list[ReleaseItem] = []
eps: list[ReleaseItem] = [] eps: list[ReleaseItem] = []
@@ -130,6 +133,7 @@ def categorize_release_groups(
rg_id_lower = rg_id.lower() if rg_id else "" rg_id_lower = rg_id.lower() if rg_id else ""
in_library = rg_id_lower in album_mbids if rg_id else False 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 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( rg_data = ReleaseItem(
id=rg_id, id=rg_id,
title=rg.get("title"), title=rg.get("title"),
@@ -137,6 +141,7 @@ def categorize_release_groups(
first_release_date=rg.get("first-release-date"), first_release_date=rg.get("first-release-date"),
in_library=in_library, in_library=in_library,
requested=requested, requested=requested,
monitored=is_monitored,
) )
if date := rg_data.first_release_date: if date := rg_data.first_release_date:
try: try:
@@ -182,6 +187,7 @@ def categorize_lidarr_albums(
track_file_count = album.get("track_file_count", 0) track_file_count = album.get("track_file_count", 0)
in_library = track_file_count > 0 or (mbid_lower in _cache_mbids) in_library = track_file_count > 0 or (mbid_lower in _cache_mbids)
requested = mbid_lower in _requested_mbids and not in_library 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( album_data = ReleaseItem(
id=mbid, id=mbid,
title=album.get("title"), title=album.get("title"),
@@ -190,6 +196,7 @@ def categorize_lidarr_albums(
year=album.get("year"), year=album.get("year"),
in_library=in_library, in_library=in_library,
requested=requested, requested=requested,
monitored=is_monitored,
) )
if album_type == "album": if album_type == "album":
albums.append(album_data) albums.append(album_data)
+30 -12
View File
@@ -167,6 +167,13 @@ class DiscoverHomepageService:
library_mbids = await self._mbid.get_library_artist_mbids(lidarr_configured) 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( seed_artists = await self._get_seed_artists(
lb_enabled, username, jf_enabled, lb_enabled, username, jf_enabled,
resolved_source=resolved_source, resolved_source=resolved_source,
@@ -214,8 +221,8 @@ class DiscoverHomepageService:
tasks["jf_most_played"] = self._jf_repo.get_most_played_artists(limit=50) tasks["jf_most_played"] = self._jf_repo.get_most_played_artists(limit=50)
if lidarr_configured: if lidarr_configured:
tasks["library_artists"] = self._lidarr_repo.get_artists_from_library() tasks["library_artists"] = self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
tasks["library_albums"] = self._lidarr_repo.get_library() tasks["library_albums"] = self._lidarr_repo.get_library(include_unmonitored=True)
results = await self._execute_tasks(tasks) results = await self._execute_tasks(tasks)
@@ -231,15 +238,15 @@ class DiscoverHomepageService:
) )
await self._enrich_because_sections_audiodb(response.because_you_listen_to) 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] = { 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( "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( "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: if resolved_source == "listenbrainz" and lb_enabled and username:
@@ -464,7 +471,8 @@ class DiscoverHomepageService:
return sections return sections
def _build_fresh_releases( 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: ) -> HomeSection | None:
releases = results.get("lb_fresh") releases = results.get("lb_fresh")
if not releases: if not releases:
@@ -475,16 +483,22 @@ class DiscoverHomepageService:
if isinstance(r, dict): if isinstance(r, dict):
mbid = r.get("release_group_mbid", "") mbid = r.get("release_group_mbid", "")
artist_mbids = r.get("artist_mbids", []) 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( items.append(HomeAlbum(
mbid=mbid, mbid=mbid,
name=r.get("title", r.get("release_group_name", "Unknown")), name=r.get("title", r.get("release_group_name", "Unknown")),
artist_name=r.get("artist_credit_name", r.get("artist_name", "")), artist_name=r.get("artist_credit_name", r.get("artist_name", "")),
artist_mbid=artist_mbids[0] if artist_mbids else None, artist_mbid=artist_mbids[0] if artist_mbids else None,
listen_count=r.get("listen_count"), 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: 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 except Exception as e: # noqa: BLE001
logger.debug(f"Skipping fresh release item: {e}") logger.debug(f"Skipping fresh release item: {e}")
continue continue
@@ -498,7 +512,8 @@ class DiscoverHomepageService:
) )
async def _build_missing_essentials( 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: ) -> HomeSection | None:
library_artists = results.get("library_artists") or [] library_artists = results.get("library_artists") or []
library_albums = results.get("library_albums") or [] library_albums = results.get("library_albums") or []
@@ -551,6 +566,7 @@ class DiscoverHomepageService:
artist_name=rg.artist_name, artist_name=rg.artist_name,
listen_count=rg.listen_count, listen_count=rg.listen_count,
in_library=False, in_library=False,
monitored=bool(monitored_mbids) and rg_mbid.lower() in monitored_mbids,
)) ))
artist_missing += 1 artist_missing += 1
@@ -904,6 +920,7 @@ class DiscoverHomepageService:
self, self,
results: dict[str, Any], results: dict[str, Any],
library_mbids: set[str], library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeSection | None: ) -> HomeSection | None:
albums = results.get("lfm_weekly_albums") or [] albums = results.get("lfm_weekly_albums") or []
if not albums: if not albums:
@@ -914,7 +931,7 @@ class DiscoverHomepageService:
items = [] items = []
for album in albums[:20]: 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: if home_album and home_album.mbid:
home_album.mbid = rg_map.get(home_album.mbid, home_album.mbid) home_album.mbid = rg_map.get(home_album.mbid, home_album.mbid)
items.append(home_album) items.append(home_album)
@@ -933,6 +950,7 @@ class DiscoverHomepageService:
self, self,
results: dict[str, Any], results: dict[str, Any],
library_mbids: set[str], library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeSection | None: ) -> HomeSection | None:
tracks = results.get("lfm_recent") or [] tracks = results.get("lfm_recent") or []
if not tracks: if not tracks:
@@ -944,7 +962,7 @@ class DiscoverHomepageService:
items = [] items = []
seen_album_mbids: set[str] = set() seen_album_mbids: set[str] = set()
for track in tracks[:30]: 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: if home_album and home_album.mbid:
resolved = rg_map.get(home_album.mbid, home_album.mbid) resolved = rg_map.get(home_album.mbid, home_album.mbid)
home_album.mbid = resolved home_album.mbid = resolved
@@ -223,7 +223,7 @@ class MbidResolutionService:
if not lidarr_configured: if not lidarr_configured:
return set() return set()
try: 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")} return {a.get("mbid", "").lower() for a in artists if a.get("mbid")}
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("Failed to fetch library artists from Lidarr") logger.warning("Failed to fetch library artists from Lidarr")
+48 -20
View File
@@ -109,15 +109,17 @@ class HomeChartsService:
self, genre: str, limit: int = 100, artist_offset: int = 0, album_offset: int = 0 self, genre: str, limit: int = 100, artist_offset: int = 0, album_offset: int = 0
) -> GenreDetailResponse: ) -> GenreDetailResponse:
lidarr_results = await asyncio.gather( lidarr_results = await asyncio.gather(
self._lidarr_repo.get_artists_from_library(), self._lidarr_repo.get_artists_from_library(include_unmonitored=True),
self._lidarr_repo.get_library(), self._lidarr_repo.get_library(include_unmonitored=True),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True, 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: if lidarr_failed:
logger.warning("Lidarr unavailable for genre '%s', proceeding with MusicBrainz data only", genre) 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_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 [] 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_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_album_mbids = {a.musicbrainz_id.lower() for a in library_albums if a.musicbrainz_id}
library_section = None library_section = None
@@ -177,6 +179,7 @@ class HomeChartsService:
image_url=None, image_url=None,
release_date=str(result.year) if result.year else None, release_date=str(result.year) if result.year else None,
in_library=result.musicbrainz_id.lower() in library_album_mbids, 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 for result in mb_album_results
] ]
@@ -203,7 +206,7 @@ class HomeChartsService:
if resolved == "lastfm" and self._lfm_repo: if resolved == "lastfm" and self._lfm_repo:
return await self._get_trending_artists_lastfm(limit) 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")} library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
ranges = ["this_week", "this_month", "this_year", "all_time"] 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} 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, offset=offset,
) )
library_artists, lb_artists = await asyncio.gather( 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( self._lb_repo.get_sitewide_top_artists(
range_=range_key, count=limit + 1, offset=offset range_=range_key, count=limit + 1, offset=offset
), ),
@@ -275,15 +278,19 @@ class HomeChartsService:
if resolved == "lastfm" and self._lfm_repo: if resolved == "lastfm" and self._lfm_repo:
return await self._get_popular_albums_lastfm(limit) 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} 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"] 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} 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) results = await self._execute_tasks(tasks)
response_data = {} response_data = {}
for r in ranges: for r in ranges:
lb_albums = results.get(r) or [] 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 featured = albums[0] if albums else None
items = albums[1:limit] if len(albums) > 1 else [] items = albums[1:limit] if len(albums) > 1 else []
response_data[r] = PopularTimeRange( response_data[r] = PopularTimeRange(
@@ -317,14 +324,17 @@ class HomeChartsService:
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
library_albums, lb_albums = await asyncio.gather( library_albums, lb_albums, monitored_mbids_result = await asyncio.gather(
self._lidarr_repo.get_library(), self._lidarr_repo.get_library(include_unmonitored=True),
self._lb_repo.get_sitewide_top_release_groups( self._lb_repo.get_sitewide_top_release_groups(
range_=range_key, count=limit + 1, offset=offset 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} 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 has_more = len(albums) > limit
items = albums[:limit] items = albums[:limit]
return PopularAlbumsRangeResponse( return PopularAlbumsRangeResponse(
@@ -337,7 +347,7 @@ class HomeChartsService:
) )
async def _get_trending_artists_lastfm(self, limit: int = 10) -> TrendingArtistsResponse: 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")} 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) lfm_artists = await self._lfm_repo.get_global_top_artists(limit=limit + 1)
artists = [ artists = [
@@ -366,10 +376,15 @@ class HomeChartsService:
async def _get_popular_albums_lastfm(self, limit: int = 10) -> PopularAlbumsResponse: async def _get_popular_albums_lastfm(self, limit: int = 10) -> PopularAlbumsResponse:
ranges = ["this_week", "this_month", "this_year", "all_time"] 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 = { library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id (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() lfm_username = self._get_lastfm_username()
if lfm_username: if lfm_username:
tasks = { tasks = {
@@ -408,6 +423,7 @@ class HomeChartsService:
image_url=album.image_url or None, image_url=album.image_url or None,
listen_count=album.playcount, listen_count=album.playcount,
in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False, 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", source="lastfm",
) )
for album in lfm_albums for album in lfm_albums
@@ -433,7 +449,7 @@ class HomeChartsService:
total_to_fetch = min(limit + offset + 1, 200) total_to_fetch = min(limit + offset + 1, 200)
lfm_artists, library_artists = await asyncio.gather( lfm_artists, library_artists = await asyncio.gather(
self._lfm_repo.get_global_top_artists(limit=total_to_fetch), 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")} library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
artists = [ artists = [
@@ -470,17 +486,20 @@ class HomeChartsService:
) )
total_to_fetch = min(limit + offset + 1, 200) 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( self._lfm_repo.get_user_top_albums(
lfm_username, lfm_username,
period=self._lastfm_period_for_range(range_key), period=self._lastfm_period_for_range(range_key),
limit=total_to_fetch, 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 = { library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id (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 = [ albums = [
HomeAlbum( HomeAlbum(
mbid=album.mbid, mbid=album.mbid,
@@ -490,6 +509,7 @@ class HomeChartsService:
image_url=album.image_url or None, image_url=album.image_url or None,
listen_count=album.playcount, listen_count=album.playcount,
in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False, 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", source="lastfm",
) )
for album in lfm_albums for album in lfm_albums
@@ -521,10 +541,15 @@ class HomeChartsService:
this_week=empty, this_month=empty, this_year=empty, all_time=empty 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 = { library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id (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"] ranges = ["this_week", "this_month", "this_year", "all_time"]
tasks = { tasks = {
r: self._lb_repo.get_user_top_release_groups( r: self._lb_repo.get_user_top_release_groups(
@@ -536,7 +561,7 @@ class HomeChartsService:
response_data: dict[str, PopularTimeRange] = {} response_data: dict[str, PopularTimeRange] = {}
for r in ranges: for r in ranges:
rgs = results.get(r) or [] 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( response_data[r] = PopularTimeRange(
range_key=r, range_key=r,
label=HomeDataTransformers.get_range_label(r), label=HomeDataTransformers.get_range_label(r),
@@ -578,16 +603,19 @@ class HomeChartsService:
has_more=False, has_more=False,
) )
library_albums, rgs = await asyncio.gather( library_albums, rgs, monitored_mbids_result = await asyncio.gather(
self._lidarr_repo.get_library(), self._lidarr_repo.get_library(include_unmonitored=True),
self._lb_repo.get_user_top_release_groups( self._lb_repo.get_user_top_release_groups(
username=lb_username, range_=range_key, count=limit + 1, offset=offset username=lb_username, range_=range_key, count=limit + 1, offset=offset
), ),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
) )
library_mbids = { library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id (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 has_more = len(albums) > limit
items = albums[:limit] items = albums[:limit]
return PopularAlbumsRangeResponse( return PopularAlbumsRangeResponse(
+7 -5
View File
@@ -162,9 +162,10 @@ class HomeService:
) )
if lidarr_configured: if lidarr_configured:
tasks["library_albums"] = self._lidarr_repo.get_library() tasks["library_albums"] = self._lidarr_repo.get_library(include_unmonitored=True)
tasks["library_artists"] = self._lidarr_repo.get_artists_from_library() 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["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: if resolved_source == "listenbrainz" and lb_enabled and username:
lb_settings = self._preferences.get_listenbrainz_connection() lb_settings = self._preferences.get_listenbrainz_connection()
@@ -195,6 +196,7 @@ class HomeService:
library_album_mbids = { library_album_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id (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) response = HomeResponse(integration_status=integration_status)
@@ -207,10 +209,10 @@ class HomeService:
results, library_artist_mbids results, library_artist_mbids
) )
response.popular_albums = self._builders.build_popular_albums_section( 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( 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.recently_played = self._builders.build_listenbrainz_recent_section(results)
response.favorite_artists = self._builders.build_listenbrainz_favorites_section(results) response.favorite_artists = self._builders.build_listenbrainz_favorites_section(results)
@@ -220,7 +222,7 @@ class HomeService:
results, library_artist_mbids results, library_artist_mbids
) )
response.your_top_albums = self._builders.build_lastfm_top_albums_section( 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.recently_played = self._builders.build_lastfm_recent_section(results)
response.favorite_artists = self._builders.build_lastfm_favorites_section(results) response.favorite_artists = self._builders.build_lastfm_favorites_section(results)
+9 -8
View File
@@ -60,24 +60,24 @@ class HomeSectionBuilders:
) )
def build_popular_albums_section( 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: ) -> HomeSection:
albums = results.get("lb_trending_albums") or [] albums = results.get("lb_trending_albums") or []
return HomeSection( return HomeSection(
title="Popular Right Now", title="Popular Right Now",
type="albums", 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, source="listenbrainz" if albums else None,
) )
def build_lb_user_top_albums_section( 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: ) -> HomeSection | None:
release_groups = results.get("lb_user_top_rgs") or [] release_groups = results.get("lb_user_top_rgs") or []
if not release_groups: if not release_groups:
return None return None
items = [ 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] for rg in release_groups[:15]
] ]
return HomeSection( return HomeSection(
@@ -95,7 +95,8 @@ class HomeSectionBuilders:
return HomeSection(title="Browse by Genre", type="genres", items=genres, source=source) return HomeSection(title="Browse by Genre", type="genres", items=genres, source=source)
def build_fresh_releases_section( 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: ) -> HomeSection | None:
releases = results.get("lb_fresh") releases = results.get("lb_fresh")
if not releases: if not releases:
@@ -103,7 +104,7 @@ class HomeSectionBuilders:
return HomeSection( return HomeSection(
title="New From Artists You Follow", title="New From Artists You Follow",
type="albums", 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", source="listenbrainz",
) )
@@ -170,12 +171,12 @@ class HomeSectionBuilders:
) )
def build_lastfm_top_albums_section( 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: ) -> HomeSection:
albums = results.get("lfm_top_albums") or [] albums = results.get("lfm_top_albums") or []
items = [ items = [
a for a in ( 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] for album in albums[:15]
) )
if a is not None if a is not None
+25 -4
View File
@@ -70,9 +70,15 @@ class HomeDataTransformers:
def lb_release_to_home( def lb_release_to_home(
self, self,
release: ListenBrainzReleaseGroup, release: ListenBrainzReleaseGroup,
library_mbids: set[str] library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeAlbum: ) -> HomeAlbum:
artist_mbid = release.artist_mbids[0] if release.artist_mbids else None 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( return HomeAlbum(
mbid=release.release_group_mbid, mbid=release.release_group_mbid,
name=release.release_group_name, name=release.release_group_name,
@@ -81,7 +87,8 @@ class HomeDataTransformers:
image_url=None, image_url=None,
release_date=None, release_date=None,
listen_count=release.listen_count, 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( def jf_item_to_artist(
@@ -130,7 +137,13 @@ class HomeDataTransformers:
self, self,
album: LastFmAlbum, album: LastFmAlbum,
library_mbids: set[str], library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeAlbum | 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( return HomeAlbum(
mbid=None, mbid=None,
name=album.name, name=album.name,
@@ -138,7 +151,8 @@ class HomeDataTransformers:
artist_mbid=None, artist_mbid=None,
image_url=album.image_url or None, image_url=album.image_url or None,
listen_count=album.playcount, 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", source="lastfm",
) )
@@ -159,14 +173,21 @@ class HomeDataTransformers:
self, self,
track: LastFmRecentTrack, track: LastFmRecentTrack,
library_mbids: set[str], library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeAlbum | 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( return HomeAlbum(
mbid=track.album_mbid, mbid=track.album_mbid,
name=track.album_name or track.track_name, name=track.album_name or track.track_name,
artist_name=track.artist_name, artist_name=track.artist_name,
artist_mbid=track.artist_mbid, artist_mbid=track.artist_mbid,
image_url=track.image_url or None, 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", source="lastfm",
) )
+11 -2
View File
@@ -167,6 +167,15 @@ class LibraryService:
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.error(f"Failed to fetch requested mbids: {e}") logger.error(f"Failed to fetch requested mbids: {e}")
raise ExternalServiceError(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]: async def get_artists(self, limit: int | None = None) -> list[LibraryArtist]:
try: try:
@@ -348,8 +357,8 @@ class LibraryService:
sync_succeeded = False sync_succeeded = False
try: try:
albums = await self._lidarr_repo.get_library() albums = await self._lidarr_repo.get_library(include_unmonitored=True)
artists = await self._lidarr_repo.get_artists_from_library() artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
albums_data = [ albums_data = [
{ {
+3 -3
View File
@@ -29,7 +29,7 @@ class AlbumPhase:
async def precache_album_data( async def precache_album_data(
self, self,
release_group_ids: list[str], release_group_ids: list[str],
monitored_mbids: set[str], library_mbids: set[str],
status_service: CacheStatusService, status_service: CacheStatusService,
library_album_mbids: dict[str, Any] = None, library_album_mbids: dict[str, Any] = None,
offset: int = 0, offset: int = 0,
@@ -48,11 +48,11 @@ class AlbumPhase:
cached_info = await album_service._cache.get(cache_key) cached_info = await album_service._cache.get(cache_key)
if not cached_info: 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 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 metadata_fetched = True
else: else:
await status_service.update_progress(index + 1, f"Cached: {rgid[:8]}...", processed_albums=offset + index + 1, generation=generation) 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") cache_filename = get_cache_filename(f"rg_{rgid}", "500")
file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin" file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin"
if not file_path.exists(): if not file_path.exists():
+5 -5
View File
@@ -210,12 +210,12 @@ class LibraryPrecacheService:
if status_service.is_cancelled(): if status_service.is_cancelled():
return return
monitored_mbids: set[str] = set() library_album_mbids_set: set[str] = set()
for a in albums: 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 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): if not is_unknown_mbid(mbid):
monitored_mbids.add(mbid.lower()) library_album_mbids_set.add(mbid.lower())
deduped_release_groups = list(monitored_mbids) deduped_release_groups = list(library_album_mbids_set)
if status_service.is_cancelled(): if status_service.is_cancelled():
return return
items_needing_metadata = [] items_needing_metadata = []
@@ -234,7 +234,7 @@ class LibraryPrecacheService:
for rgid in deduped_release_groups: for rgid in deduped_release_groups:
if rgid in processed_albums: if rgid in processed_albums:
continue continue
if rgid.lower() in monitored_mbids: if rgid.lower() in library_album_mbids_set:
cache_filename = get_cache_filename(f"rg_{rgid}", "500") cache_filename = get_cache_filename(f"rg_{rgid}", "500")
file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin" file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin"
cover_paths.append((rgid, file_path)) 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) already_cached = len(deduped_release_groups) - len(items_to_process) - len(processed_albums)
if items_to_process: if items_to_process:
await status_service.update_phase('albums', len(items_to_process), generation=generation) 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: else:
await status_service.skip_phase('albums', generation=generation) await status_service.skip_phase('albums', generation=generation)
+17 -4
View File
@@ -164,7 +164,7 @@ class SearchService:
limits["albums"] = limit_albums limits["albums"] = limit_albums
try: 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( self._mb_repo.search_grouped(
query, query,
limits=limits, limits=limits,
@@ -173,10 +173,11 @@ class SearchService:
), ),
self._lidarr_repo.get_library_mbids(include_release_ids=True), self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(), self._lidarr_repo.get_queue(),
self._lidarr_repo.get_monitored_no_files_mbids(),
) )
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.error(f"Search gather failed unexpectedly: {e}") 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: if grouped is None:
logger.warning("MusicBrainz search returned no results or failed") logger.warning("MusicBrainz search returned no results or failed")
@@ -188,10 +189,13 @@ class SearchService:
else: else:
queued_mbids = set() queued_mbids = set()
monitored_mbids = monitored_mbids_raw or set()
for item in grouped.get("albums", []): for item in grouped.get("albums", []):
mbid_lower = (item.musicbrainz_id or "").lower() mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library 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", []) all_results = grouped.get("artists", []) + grouped.get("albums", [])
await self._apply_audiodb_search_overlay(all_results) await self._apply_audiodb_search_overlay(all_results)
@@ -246,9 +250,10 @@ class SearchService:
return [], None return [], None
if bucket == "albums": 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_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(), self._lidarr_repo.get_queue(),
self._lidarr_repo.get_monitored_no_files_mbids(),
) )
library_mbids = library_mbids_raw or set() library_mbids = library_mbids_raw or set()
if queue_items_raw: if queue_items_raw:
@@ -256,10 +261,13 @@ class SearchService:
else: else:
queued_mbids = set() queued_mbids = set()
monitored_mbids = monitored_mbids_raw or set()
for item in results: for item in results:
mbid_lower = (item.musicbrainz_id or "").lower() mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library 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) await self._apply_audiodb_search_overlay(results)
@@ -288,9 +296,10 @@ class SearchService:
grouped = grouped or {"artists": [], "albums": []} 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_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(), self._lidarr_repo.get_queue(),
self._lidarr_repo.get_monitored_no_files_mbids(),
) )
library_mbids = library_mbids_raw or set() library_mbids = library_mbids_raw or set()
if queue_items_raw: if queue_items_raw:
@@ -298,10 +307,13 @@ class SearchService:
else: else:
queued_mbids = set() queued_mbids = set()
monitored_mbids = monitored_mbids_raw or set()
for item in grouped.get("albums", []): for item in grouped.get("albums", []):
mbid_lower = (item.musicbrainz_id or "").lower() mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library 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] = [] suggestions: list[SuggestResult] = []
for item in grouped.get("artists", []) + grouped.get("albums", []): for item in grouped.get("artists", []) + grouped.get("albums", []):
@@ -313,6 +325,7 @@ class SearchService:
musicbrainz_id=item.musicbrainz_id, musicbrainz_id=item.musicbrainz_id,
in_library=item.in_library, in_library=item.in_library,
requested=item.requested, requested=item.requested,
monitored=item.monitored,
disambiguation=item.disambiguation, disambiguation=item.disambiguation,
score=item.score, score=item.score,
)) ))
@@ -8,6 +8,10 @@ from infrastructure.cache.disk_cache import DiskMetadataCache
from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages 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 @pytest.mark.asyncio
async def test_set_album_serializes_msgspec_struct_as_mapping(tmp_path): async def test_set_album_serializes_msgspec_struct_as_mapping(tmp_path):
cache = DiskMetadataCache(base_path=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) 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" cache_file = tmp_path / "persistent" / "albums" / f"{cache_hash}.json"
payload = json.loads(cache_file.read_text()) 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) cache = DiskMetadataCache(base_path=tmp_path)
mbid = "8e1e9e51-38dc-4df3-8027-a0ada37d4674" 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 = tmp_path / "persistent" / "albums" / f"{cache_hash}.json"
cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps("AlbumInfo(title='Corrupt')")) 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["fanart_url"] == "https://example.com/fanart.jpg"
assert result["lookup_source"] == "mbid" 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" data_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json"
assert data_file.exists() 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["album_back_url"] == "https://example.com/album_back.jpg"
assert result["lookup_source"] == "name" 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" persistent_file = tmp_path / "persistent" / "audiodb_albums" / f"{cache_hash}.json"
assert persistent_file.exists() 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) 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" persistent_file = tmp_path / "persistent" / "audiodb_artists" / f"{cache_hash}.json"
recent_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json" recent_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json"
assert persistent_file.exists() assert persistent_file.exists()
@@ -26,6 +26,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2023-01-15", "releaseDate": "2023-01-15",
"added": "2023-01-10T12:00:00Z", "added": "2023-01-10T12:00:00Z",
"images": [], "images": [],
"statistics": {"trackFileCount": 5},
"artist": { "artist": {
"artistName": "Artist A", "artistName": "Artist A",
"foreignArtistId": "artist-a-mbid", "foreignArtistId": "artist-a-mbid",
@@ -39,6 +40,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2024-06-01", "releaseDate": "2024-06-01",
"added": "2024-06-01T08:00:00Z", "added": "2024-06-01T08:00:00Z",
"images": [], "images": [],
"statistics": {"trackFileCount": 8},
"artist": { "artist": {
"artistName": "Artist B", "artistName": "Artist B",
"foreignArtistId": "artist-b-mbid", "foreignArtistId": "artist-b-mbid",
@@ -52,6 +54,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2020-03-01", "releaseDate": "2020-03-01",
"added": "2020-03-01T00:00:00Z", "added": "2020-03-01T00:00:00Z",
"images": [], "images": [],
"statistics": {"trackFileCount": 3},
"artist": { "artist": {
"artistName": "Artist C", "artistName": "Artist C",
"foreignArtistId": "artist-c-mbid", "foreignArtistId": "artist-c-mbid",
+3 -3
View File
@@ -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") 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() 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, lidarr_repo, _ = _make_service()
service._get_cached_album_info = AsyncMock(return_value=None) service._get_cached_album_info = AsyncMock(return_value=None)
lidarr_repo.is_configured.return_value = True 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( lidarr_repo.get_album_tracks = AsyncMock(
return_value=[ return_value=[
{ {
@@ -135,7 +135,7 @@ async def test_get_album_tracks_info_multi_disc_same_track_numbers():
service, lidarr_repo, _ = _make_service() service, lidarr_repo, _ = _make_service()
service._get_cached_album_info = AsyncMock(return_value=None) service._get_cached_album_info = AsyncMock(return_value=None)
lidarr_repo.is_configured.return_value = True 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( lidarr_repo.get_album_tracks = AsyncMock(
return_value=[ return_value=[
{"track_number": 1, "disc_number": 1, "title": "Intro", "duration_ms": 1000}, {"track_number": 1, "disc_number": 1, "title": "Intro", "duration_ms": 1000},
@@ -42,6 +42,7 @@ def _make_service(*, cached_artist: ArtistInfo | None = None) -> tuple[ArtistSer
lidarr_repo.is_configured.return_value = False lidarr_repo.is_configured.return_value = False
lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_requested_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()) lidarr_repo.get_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock() wikidata_repo = AsyncMock()
@@ -53,6 +53,7 @@ def _make_service(
lidarr_repo.get_artist_details = AsyncMock(return_value=lidarr_artist) lidarr_repo.get_artist_details = AsyncMock(return_value=lidarr_artist)
lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_requested_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()) lidarr_repo.get_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock() wikidata_repo = AsyncMock()
@@ -52,6 +52,7 @@ def _search_service(audiodb: MagicMock | None = None) -> SearchService:
lidarr_repo = MagicMock() lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[]) lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock() coverart_repo = MagicMock()
prefs = MagicMock() prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[]) prefs.get_preferences.return_value = MagicMock(secondary_types=[])
@@ -56,6 +56,7 @@ def _search_service(audiodb=None) -> SearchService:
lidarr_repo = MagicMock() lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[]) lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock() coverart_repo = MagicMock()
prefs = MagicMock() prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[]) prefs.get_preferences.return_value = MagicMock(secondary_types=[])
@@ -59,6 +59,8 @@ def _make_service(
else: else:
lidarr_repo.get_queue = AsyncMock(return_value=queue_items or []) lidarr_repo.get_queue = AsyncMock(return_value=queue_items or [])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock() coverart_repo = MagicMock()
preferences_service = MagicMock() preferences_service = MagicMock()
preferences_service.get_preferences.return_value = _make_preferences() 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 = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[]) lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock() coverart_repo = MagicMock()
preferences_service = MagicMock() preferences_service = MagicMock()
+1
View File
@@ -156,6 +156,7 @@ def _make_search_service(audiodb_service=None) -> SearchService:
lidarr_repo = MagicMock() lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[]) lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock() coverart_repo = MagicMock()
prefs = MagicMock() prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[]) prefs.get_preferences.return_value = MagicMock(secondary_types=[])
+3 -3
View File
@@ -66,7 +66,7 @@ class TestCooldownOnlyOnSuccess:
async def test_retry_after_failed_sync_is_not_cooldown_blocked(self): async def test_retry_after_failed_sync_is_not_cooldown_blocked(self):
call_count = 0 call_count = 0
async def fail_then_succeed(): async def fail_then_succeed(**kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
@@ -94,7 +94,7 @@ class TestSyncFutureDedup:
call_count = 0 call_count = 0
sync_event = asyncio.Event() sync_event = asyncio.Event()
async def slow_get_library(): async def slow_get_library(**kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
sync_event.set() sync_event.set()
@@ -118,7 +118,7 @@ class TestSyncFutureDedup:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_concurrent_sync_failure_propagates_to_waiter(self): async def test_concurrent_sync_failure_propagates_to_waiter(self):
"""When the producer fails, deduped waiters get the real exception.""" """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) await asyncio.sleep(0.05)
raise RuntimeError("Lidarr DNS failure") raise RuntimeError("Lidarr DNS failure")
@@ -5,6 +5,8 @@
import { libraryStore } from '$lib/stores/library'; import { libraryStore } from '$lib/stores/library';
import { integrationStore } from '$lib/stores/integration'; import { integrationStore } from '$lib/stores/integration';
import { requestAlbum } from '$lib/utils/albumRequest'; 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 { formatListenCount } from '$lib/utils/formatting';
import { getListenTitle } from '$lib/utils/enrichment'; import { getListenTitle } from '$lib/utils/enrichment';
import { Download, Music2 } from 'lucide-svelte'; import { Download, Music2 } from 'lucide-svelte';
@@ -29,6 +31,7 @@
let listenTitle = $derived(getListenTitle(enrichmentSource, 'album')); let listenTitle = $derived(getListenTitle(enrichmentSource, 'album'));
let requesting = $state(false); let requesting = $state(false);
let monitoredLoading = $state(false);
let inLibrary = $derived( let inLibrary = $derived(
libraryStore.isInLibrary(album.musicbrainz_id) || album.in_library || false libraryStore.isInLibrary(album.musicbrainz_id) || album.in_library || false
@@ -38,6 +41,11 @@
!album.in_library && !album.in_library &&
(album.requested || libraryStore.isRequested(album.musicbrainz_id)) (album.requested || libraryStore.isRequested(album.musicbrainz_id))
); );
let isMonitored = $derived(
!inLibrary &&
!isRequested &&
(album.monitored || libraryStore.isMonitored(album.musicbrainz_id))
);
async function handleRequest(e: Event) { async function handleRequest(e: Event) {
e.stopPropagation(); 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() { function handleDeleted() {
album.in_library = false; album.in_library = false;
album.requested = false; album.requested = false;
album.monitored = false;
album = album; album = album;
onremoved?.(); onremoved?.();
} }
@@ -161,6 +183,15 @@
artistName={album.artist || 'Unknown'} artistName={album.artist || 'Unknown'}
ondeleted={handleDeleted} ondeleted={handleDeleted}
/> />
{:else if isMonitored}
<LibraryBadge
status="monitored"
musicbrainzId={album.musicbrainz_id}
albumTitle={album.title}
artistName={album.artist || 'Unknown'}
ontogglemonitored={handleToggleMonitored}
{monitoredLoading}
/>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -28,6 +28,7 @@
musicbrainz_id: da.musicbrainz_id, musicbrainz_id: da.musicbrainz_id,
in_library: da.in_library, in_library: da.in_library,
requested: da.requested, requested: da.requested,
monitored: da.monitored,
cover_url: da.cover_url cover_url: da.cover_url
}; };
} }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check } from 'lucide-svelte'; import { Check, Bookmark } from 'lucide-svelte';
import AlbumImage from '$lib/components/AlbumImage.svelte'; import AlbumImage from '$lib/components/AlbumImage.svelte';
import AlbumCardOverlay from '$lib/components/AlbumCardOverlay.svelte'; import AlbumCardOverlay from '$lib/components/AlbumCardOverlay.svelte';
import type { HomeAlbum } from '$lib/types'; import type { HomeAlbum } from '$lib/types';
@@ -40,6 +40,10 @@
<div class="absolute top-2 left-2 z-20 badge badge-success badge-sm gap-1 opacity-90"> <div class="absolute top-2 left-2 z-20 badge badge-success badge-sm gap-1 opacity-90">
<Check class="w-3 h-3" /> <Check class="w-3 h-3" />
</div> </div>
{:else if album.monitored && !album.requested}
<div class="absolute top-2 left-2 z-20 badge badge-neutral badge-sm gap-1 opacity-90">
<Bookmark class="w-3 h-3" />
</div>
{/if} {/if}
{#if album.mbid && album.in_library} {#if album.mbid && album.in_library}
<AlbumCardOverlay <AlbumCardOverlay
@@ -10,6 +10,7 @@
ArrowRight, ArrowRight,
X, X,
Check, Check,
Bookmark,
Music2, Music2,
Tv, Tv,
Sparkles, Sparkles,
@@ -184,7 +185,7 @@
{/if} {/if}
{#if item.in_library} {#if item.in_library}
<div class="absolute top-2 right-2 badge badge-success badge-sm"> <div class="absolute top-2 right-2 badge badge-success badge-sm">
<Check class="w-3 h-3" /> <Check class="w-3.5 h-3.5" />
</div> </div>
{/if} {/if}
</figure> </figure>
@@ -217,7 +218,11 @@
/> />
{#if item.in_library} {#if item.in_library}
<div class="absolute top-2 left-2 z-20 badge badge-success badge-sm"> <div class="absolute top-2 left-2 z-20 badge badge-success badge-sm">
<Check class="w-3 h-3" /> <Check class="w-3.5 h-3.5" />
</div>
{:else if item.monitored && !item.requested}
<div class="absolute top-2 left-2 z-20 badge badge-neutral badge-sm">
<Bookmark class="w-3.5 h-3.5" />
</div> </div>
{/if} {/if}
{#if item.mbid && item.in_library} {#if item.mbid && item.in_library}
@@ -32,6 +32,7 @@
musicbrainz_id: rg.id, musicbrainz_id: rg.id,
in_library: libraryStore.isInLibrary(rg.id) || rg.in_library, in_library: libraryStore.isInLibrary(rg.id) || rg.in_library,
requested: rg.requested, requested: rg.requested,
monitored: rg.monitored,
cover_url: null, cover_url: null,
type_info: rg.type type_info: rg.type
}; };
+60 -15
View File
@@ -1,18 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Check, Clock, Trash2 } from 'lucide-svelte'; import { Check, Clock, Trash2, Bookmark, BookmarkX } from 'lucide-svelte';
import { colors } from '$lib/colors'; import { colors } from '$lib/colors';
import { STATUS_COLORS } from '$lib/constants'; import { STATUS_COLORS } from '$lib/constants';
import DeleteAlbumModal from './DeleteAlbumModal.svelte'; import DeleteAlbumModal from './DeleteAlbumModal.svelte';
import ArtistRemovedModal from './ArtistRemovedModal.svelte'; import ArtistRemovedModal from './ArtistRemovedModal.svelte';
interface Props { interface Props {
status: 'library' | 'requested'; status: 'library' | 'requested' | 'monitored';
musicbrainzId: string; musicbrainzId: string;
albumTitle: string; albumTitle: string;
artistName: string; artistName: string;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
positioning?: string; positioning?: string;
ondeleted?: (result: { artist_removed: boolean; artist_name?: string | null }) => void; ondeleted?: (result: { artist_removed: boolean; artist_name?: string | null }) => void;
ontogglemonitored?: () => void;
monitoredLoading?: boolean;
} }
let { let {
@@ -22,7 +24,9 @@
artistName, artistName,
size = 'md', size = 'md',
positioning = '', positioning = '',
ondeleted ondeleted,
ontogglemonitored,
monitoredLoading = false
}: Props = $props(); }: Props = $props();
let showDeleteModal = $state(false); let showDeleteModal = $state(false);
@@ -31,11 +35,11 @@
const sizeClasses = $derived( const sizeClasses = $derived(
{ {
sm: { button: 'p-0.5', icon: 'w-2.5 h-2.5', strokeWidth: status === 'library' ? '3' : '2' }, sm: { button: 'p-1', icon: 'w-3.5 h-3.5', strokeWidth: status === 'library' ? '3' : '2' },
md: { button: 'p-1.5', icon: 'h-4 w-4', strokeWidth: status === 'library' ? '3' : '2' }, md: { button: 'p-1.5', icon: 'h-5 w-5', strokeWidth: status === 'library' ? '3' : '2' },
lg: { lg: {
button: 'p-0', button: 'p-0',
icon: 'h-4 w-4 sm:h-5 sm:w-5', icon: 'h-5 w-5 sm:h-6 sm:w-6',
strokeWidth: status === 'library' ? '3' : '2' strokeWidth: status === 'library' ? '3' : '2'
} }
}[size] }[size]
@@ -44,11 +48,21 @@
const lgButtonClass = $derived( const lgButtonClass = $derived(
size === 'lg' ? 'w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center' : '' size === 'lg' ? 'w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center' : ''
); );
const bgColor = $derived(status === 'library' ? colors.accent : STATUS_COLORS.REQUESTED); const bgColor = $derived(
status === 'library'
? colors.accent
: status === 'requested'
? STATUS_COLORS.REQUESTED
: STATUS_COLORS.MONITORED
);
function handleClick(e: Event) { function handleClick(e: Event) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (status === 'monitored') {
ontogglemonitored?.();
return;
}
showDeleteModal = true; showDeleteModal = true;
} }
@@ -66,31 +80,62 @@
class="{positioning} rounded-full shadow-sm transition-colors duration-200 group/badge {sizeClasses.button} {lgButtonClass}" class="{positioning} rounded-full shadow-sm transition-colors duration-200 group/badge {sizeClasses.button} {lgButtonClass}"
style="background-color: {bgColor};" style="background-color: {bgColor};"
onclick={handleClick} onclick={handleClick}
onmouseenter={(e) => { onmouseenter={status === 'monitored'
e.currentTarget.style.backgroundColor = '#ef4444'; ? (e) => {
}} e.currentTarget.style.filter = 'brightness(1.3)';
}
: (e) => {
e.currentTarget.style.backgroundColor = '#ef4444';
}}
onmouseleave={(e) => { onmouseleave={(e) => {
e.currentTarget.style.backgroundColor = bgColor; e.currentTarget.style.backgroundColor = bgColor;
e.currentTarget.style.filter = '';
}} }}
aria-label={status === 'library' ? 'Remove from library' : 'Remove request'} aria-label={status === 'library'
? 'Remove from library'
: status === 'requested'
? 'Remove request'
: 'Toggle monitoring'}
disabled={monitoredLoading}
> >
{#if status === 'library'} {#if monitoredLoading}
<span class="loading loading-spinner {sizeClasses.icon}" style="color: {colors.secondary};"
></span>
{:else if status === 'library'}
<Check <Check
class="{sizeClasses.icon} group-hover/badge:hidden" class="{sizeClasses.icon} group-hover/badge:hidden"
color={colors.secondary} color={colors.secondary}
strokeWidth={Number(sizeClasses.strokeWidth)} strokeWidth={Number(sizeClasses.strokeWidth)}
/> />
{:else} {:else if status === 'requested'}
<Clock <Clock
class="{sizeClasses.icon} group-hover/badge:hidden" class="{sizeClasses.icon} group-hover/badge:hidden"
color={colors.secondary} color={colors.secondary}
strokeWidth={Number(sizeClasses.strokeWidth)} strokeWidth={Number(sizeClasses.strokeWidth)}
/> />
{:else}
<Bookmark
class="{sizeClasses.icon} group-hover/badge:hidden"
color={colors.secondary}
strokeWidth={Number(sizeClasses.strokeWidth)}
/>
{/if}
{#if status === 'monitored' && !monitoredLoading}
<BookmarkX
class="{sizeClasses.icon} hidden group-hover/badge:block"
color={colors.secondary}
strokeWidth={2}
/>
{:else if status !== 'monitored'}
<Trash2
class="{sizeClasses.icon} hidden group-hover/badge:block"
color="white"
strokeWidth={2}
/>
{/if} {/if}
<Trash2 class="{sizeClasses.icon} hidden group-hover/badge:block" color="white" strokeWidth={2} />
</button> </button>
{#if showDeleteModal} {#if status !== 'monitored' && showDeleteModal}
<DeleteAlbumModal <DeleteAlbumModal
{albumTitle} {albumTitle}
{artistName} {artistName}
+47 -23
View File
@@ -3,6 +3,8 @@
import { ChevronDown, Download } from 'lucide-svelte'; import { ChevronDown, Download } from 'lucide-svelte';
import { colors } from '$lib/colors'; import { colors } from '$lib/colors';
import { libraryStore } from '$lib/stores/library'; import { libraryStore } from '$lib/stores/library';
import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
import { toastStore } from '$lib/stores/toast';
import AlbumImage from './AlbumImage.svelte'; import AlbumImage from './AlbumImage.svelte';
import LibraryBadge from './LibraryBadge.svelte'; import LibraryBadge from './LibraryBadge.svelte';
@@ -12,6 +14,7 @@
year?: number | null; year?: number | null;
in_library?: boolean; in_library?: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
} }
interface RemoveResult { interface RemoveResult {
@@ -46,11 +49,31 @@
function handleDeleted(rg: Release, result: RemoveResult) { function handleDeleted(rg: Release, result: RemoveResult) {
rg.in_library = false; rg.in_library = false;
rg.requested = false; rg.requested = false;
rg.monitored = false;
releases = releases; releases = releases;
onRemoved?.(result); onRemoved?.(result);
} }
</script> </script>
{#snippet requestButton(rg: Release, ariaLabel: string)}
<button
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity duration-200 border-none flex items-center justify-center shadow-sm"
style="background-color: {colors.accent};"
onclick={(e) => {
e.stopPropagation();
onRequest(rg.id, rg.title);
}}
disabled={requestingIds.has(rg.id)}
aria-label={ariaLabel}
>
{#if requestingIds.has(rg.id)}
<span class="loading loading-spinner loading-xs" style="color: {colors.secondary};"></span>
{:else}
<Download class="h-4 w-4 sm:h-5 sm:w-5" color={colors.secondary} strokeWidth={2.5} />
{/if}
</button>
{/snippet}
<div class="mb-6"> <div class="mb-6">
<div class="bg-base-300 rounded-t-box"> <div class="bg-base-300 rounded-t-box">
<button <button
@@ -105,30 +128,31 @@
size="lg" size="lg"
ondeleted={(result) => handleDeleted(rg, result)} ondeleted={(result) => handleDeleted(rg, result)}
/> />
{:else if !libraryStore.isInLibrary(rg.id) && !libraryStore.isRequested(rg.id) && (rg.monitored || libraryStore.isMonitored(rg.id))}
<div class="flex items-center gap-1.5">
{@render requestButton(rg, `Request ${rg.title}`)}
<LibraryBadge
status="monitored"
musicbrainzId={rg.id}
albumTitle={rg.title}
{artistName}
size="lg"
ontogglemonitored={async () => {
try {
await toggleAlbumMonitored(rg.id, false);
rg.monitored = false;
releases = releases;
} catch {
toastStore.show({
message: 'Failed to update monitoring status',
type: 'error'
});
}
}}
/>
</div>
{:else} {:else}
<button {@render requestButton(rg, `Request ${title.toLowerCase().slice(0, -1)}`)}
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity duration-200 border-none flex items-center justify-center shadow-sm"
style="background-color: {colors.accent};"
onclick={(e) => {
e.stopPropagation();
onRequest(rg.id, rg.title);
}}
disabled={requestingIds.has(rg.id)}
aria-label="Request {title.toLowerCase().slice(0, -1)}"
>
{#if requestingIds.has(rg.id)}
<span
class="loading loading-spinner loading-xs"
style="color: {colors.secondary};"
></span>
{:else}
<Download
class="h-4 w-4 sm:h-5 sm:w-5"
color={colors.secondary}
strokeWidth={2.5}
/>
{/if}
</button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -271,6 +271,9 @@
{#if result.requested} {#if result.requested}
<span class="badge badge-sm badge-warning">Requested</span> <span class="badge badge-sm badge-warning">Requested</span>
{/if} {/if}
{#if result.monitored && !result.in_library && !result.requested}
<span class="badge badge-sm badge-neutral">Monitored</span>
{/if}
</div> </div>
</li> </li>
{/each} {/each}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, Search } from 'lucide-svelte'; import { Check, Bookmark, Search } from 'lucide-svelte';
import type { HomeAlbum, HomeArtist } from '$lib/types'; import type { HomeAlbum, HomeArtist } from '$lib/types';
import { formatListenCount } from '$lib/utils/formatting'; import { formatListenCount } from '$lib/utils/formatting';
import AlbumImage from './AlbumImage.svelte'; import AlbumImage from './AlbumImage.svelte';
@@ -82,6 +82,11 @@
<Check class="h-3 w-3" /> <Check class="h-3 w-3" />
In Library In Library
</div> </div>
{:else if item.monitored && !('requested' in item && item.requested)}
<div class="badge badge-neutral">
<Bookmark class="h-3 w-3" />
Monitored
</div>
{/if} {/if}
</div> </div>
{#if isAlbum(item) && item.mbid && item.in_library} {#if isAlbum(item) && item.mbid && item.in_library}
@@ -127,6 +132,10 @@
<div class="badge badge-success badge-sm absolute left-1 top-1 z-20"> <div class="badge badge-success badge-sm absolute left-1 top-1 z-20">
<Check class="h-3 w-3" /> <Check class="h-3 w-3" />
</div> </div>
{:else if item.monitored && !('requested' in item && item.requested)}
<div class="badge badge-neutral badge-sm absolute left-1 top-1 z-20">
<Bookmark class="h-3 w-3" />
</div>
{/if} {/if}
{#if item.mbid && item.in_library} {#if item.mbid && item.in_library}
<AlbumCardOverlay <AlbumCardOverlay
@@ -6,6 +6,8 @@
import { colors } from '$lib/colors'; import { colors } from '$lib/colors';
import { libraryStore } from '$lib/stores/library'; import { libraryStore } from '$lib/stores/library';
import { requestAlbum } from '$lib/utils/albumRequest'; import { requestAlbum } from '$lib/utils/albumRequest';
import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
import { toastStore } from '$lib/stores/toast';
import AlbumImage from './AlbumImage.svelte'; import AlbumImage from './AlbumImage.svelte';
import LibraryBadge from './LibraryBadge.svelte'; import LibraryBadge from './LibraryBadge.svelte';
import LastFmPlaceholder from './LastFmPlaceholder.svelte'; import LastFmPlaceholder from './LastFmPlaceholder.svelte';
@@ -24,12 +26,14 @@
let libraryMbids = new SvelteSet<string>(); let libraryMbids = new SvelteSet<string>();
let requestedMbids = new SvelteSet<string>(); let requestedMbids = new SvelteSet<string>();
let monitoredMbids = new SvelteSet<string>();
let storeInitialized = $state(false); let storeInitialized = $state(false);
onMount(() => { onMount(() => {
const unsubscribe = libraryStore.subscribe((state) => { const unsubscribe = libraryStore.subscribe((state) => {
libraryMbids = new SvelteSet(state.mbidSet); libraryMbids = new SvelteSet(state.mbidSet);
requestedMbids = new SvelteSet(state.requestedSet); requestedMbids = new SvelteSet(state.requestedSet);
monitoredMbids = new SvelteSet(state.monitoredSet);
storeInitialized = state.initialized; storeInitialized = state.initialized;
}); });
return unsubscribe; return unsubscribe;
@@ -48,6 +52,14 @@
return requestedMbids.has(mbid) && !libraryMbids.has(mbid); return requestedMbids.has(mbid) && !libraryMbids.has(mbid);
} }
function isMonitored(album: TopAlbum): boolean {
if (isInLibrary(album) || isRequested(album)) return false;
const mbid = album.release_group_mbid?.toLowerCase();
if (!mbid) return false;
if (storeInitialized) return monitoredMbids.has(mbid);
return album.monitored || monitoredMbids.has(mbid);
}
function isRequesting(album: TopAlbum): boolean { function isRequesting(album: TopAlbum): boolean {
return album.release_group_mbid ? requestingIds.has(album.release_group_mbid) : false; return album.release_group_mbid ? requestingIds.has(album.release_group_mbid) : false;
} }
@@ -129,6 +141,26 @@
size="sm" size="sm"
positioning="absolute -bottom-1 -right-1" positioning="absolute -bottom-1 -right-1"
/> />
{:else if isMonitored(album)}
<LibraryBadge
status="monitored"
musicbrainzId={album.release_group_mbid}
albumTitle={album.title}
artistName={album.artist_name || 'Unknown'}
size="sm"
positioning="absolute -bottom-1 -right-1"
ontogglemonitored={async () => {
if (!album.release_group_mbid) return;
try {
await toggleAlbumMonitored(album.release_group_mbid, false);
} catch {
toastStore.show({
message: 'Failed to update monitoring status',
type: 'error'
});
}
}}
/>
{/if} {/if}
</div> </div>
@@ -143,7 +175,7 @@
</p> </p>
</div> </div>
{#if !isInLibrary(album) && !isRequested(album)} {#if !isInLibrary(album) && !isRequested(album) && !isMonitored(album)}
<button <button
type="button" type="button"
class="btn btn-circle btn-sm opacity-0 group-hover:opacity-100 transition-all shrink-0 hover:scale-110 hover:brightness-110" class="btn btn-circle btn-sm opacity-0 group-hover:opacity-100 transition-all shrink-0 hover:scale-110 hover:brightness-110"
+2 -1
View File
@@ -131,7 +131,8 @@ export const PLACEHOLDER_COLORS = {
} as const; } as const;
export const STATUS_COLORS = { export const STATUS_COLORS = {
REQUESTED: '#F59E0B' REQUESTED: '#F59E0B',
MONITORED: '#6B7280'
} as const; } as const;
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+66 -18
View File
@@ -6,6 +6,7 @@ import { api } from '$lib/api/client';
export interface LibraryState { export interface LibraryState {
mbidSet: Set<string>; mbidSet: Set<string>;
requestedSet: Set<string>; requestedSet: Set<string>;
monitoredSet: Set<string>;
loading: boolean; loading: boolean;
lastUpdated: number | null; lastUpdated: number | null;
initialized: boolean; initialized: boolean;
@@ -14,6 +15,7 @@ export interface LibraryState {
const initialState: LibraryState = { const initialState: LibraryState = {
mbidSet: new Set(), mbidSet: new Set(),
requestedSet: new Set(), requestedSet: new Set(),
monitoredSet: new Set(),
loading: false, loading: false,
lastUpdated: null, lastUpdated: null,
initialized: false initialized: false
@@ -22,6 +24,7 @@ const initialState: LibraryState = {
type LibraryCacheData = { type LibraryCacheData = {
mbids: string[]; mbids: string[];
requested: string[]; requested: string[];
monitored: string[];
}; };
function createLibraryStore() { function createLibraryStore() {
@@ -33,18 +36,24 @@ function createLibraryStore() {
function normalizeCachedData(data: LibraryCacheData | string[]): LibraryCacheData { function normalizeCachedData(data: LibraryCacheData | string[]): LibraryCacheData {
if (Array.isArray(data)) { if (Array.isArray(data)) {
return { mbids: data, requested: [] }; return { mbids: data, requested: [], monitored: [] };
} }
return { return {
mbids: data.mbids ?? [], mbids: data.mbids ?? [],
requested: data.requested ?? [] requested: data.requested ?? [],
monitored: data.monitored ?? []
}; };
} }
function persistState(mbidSet: Set<string>, requestedSet: Set<string>) { function persistState(
mbidSet: Set<string>,
requestedSet: Set<string>,
monitoredSet: Set<string>
) {
cache.set({ cache.set({
mbids: [...mbidSet], mbids: [...mbidSet],
requested: [...requestedSet] requested: [...requestedSet],
monitored: [...monitoredSet]
}); });
} }
@@ -57,8 +66,9 @@ function createLibraryStore() {
const normalized = normalizeCachedData(cached.data); const normalized = normalizeCachedData(cached.data);
const mbids = normalized.mbids.map((m) => m.toLowerCase()); const mbids = normalized.mbids.map((m) => m.toLowerCase());
const requested = normalized.requested.map((m) => m.toLowerCase()); const requested = normalized.requested.map((m) => m.toLowerCase());
const monitored = normalized.monitored.map((m) => m.toLowerCase());
if (mbids.length === 0 && requested.length === 0) { if (mbids.length === 0 && requested.length === 0 && monitored.length === 0) {
await fetchLibraryMbids(false); await fetchLibraryMbids(false);
return; return;
} }
@@ -67,6 +77,7 @@ function createLibraryStore() {
...s, ...s,
mbidSet: new Set(mbids), mbidSet: new Set(mbids),
requestedSet: new Set(requested), requestedSet: new Set(requested),
monitoredSet: new Set(monitored),
lastUpdated: cached.timestamp, lastUpdated: cached.timestamp,
initialized: true initialized: true
})); }));
@@ -86,22 +97,26 @@ function createLibraryStore() {
} }
try { try {
const data = await api.global.get<{ mbids?: string[]; requested_mbids?: string[] }>( const data = await api.global.get<{
'/api/v1/library/mbids' mbids?: string[];
); requested_mbids?: string[];
monitored_mbids?: string[];
}>('/api/v1/library/mbids');
const mbids: string[] = (data.mbids || []).map((m: string) => m.toLowerCase()); const mbids: string[] = (data.mbids || []).map((m: string) => m.toLowerCase());
const requested: string[] = (data.requested_mbids || []).map((m: string) => m.toLowerCase()); const requested: string[] = (data.requested_mbids || []).map((m: string) => m.toLowerCase());
const monitored: string[] = (data.monitored_mbids || []).map((m: string) => m.toLowerCase());
update((s) => ({ update((s) => ({
...s, ...s,
mbidSet: new Set(mbids), mbidSet: new Set(mbids),
requestedSet: new Set(requested), requestedSet: new Set(requested),
monitoredSet: new Set(monitored),
loading: false, loading: false,
lastUpdated: Date.now(), lastUpdated: Date.now(),
initialized: true initialized: true
})); }));
cache.set({ mbids, requested }); cache.set({ mbids, requested, monitored });
} catch { } catch {
if (!background) { if (!background) {
update((s) => ({ ...s, loading: false, initialized: true })); update((s) => ({ ...s, loading: false, initialized: true }));
@@ -121,8 +136,10 @@ function createLibraryStore() {
newSet.add(mbid.toLowerCase()); newSet.add(mbid.toLowerCase());
const newRequested = new Set(s.requestedSet); const newRequested = new Set(s.requestedSet);
newRequested.delete(mbid.toLowerCase()); newRequested.delete(mbid.toLowerCase());
persistState(newSet, newRequested); const newMonitored = new Set(s.monitoredSet);
return { ...s, mbidSet: newSet, requestedSet: newRequested }; newMonitored.delete(mbid.toLowerCase());
persistState(newSet, newRequested, newMonitored);
return { ...s, mbidSet: newSet, requestedSet: newRequested, monitoredSet: newMonitored };
}); });
} }
@@ -132,8 +149,10 @@ function createLibraryStore() {
newSet.delete(mbid.toLowerCase()); newSet.delete(mbid.toLowerCase());
const newRequested = new Set(s.requestedSet); const newRequested = new Set(s.requestedSet);
newRequested.delete(mbid.toLowerCase()); newRequested.delete(mbid.toLowerCase());
persistState(newSet, newRequested); const newMonitored = new Set(s.monitoredSet);
return { ...s, mbidSet: newSet, requestedSet: newRequested }; newMonitored.delete(mbid.toLowerCase());
persistState(newSet, newRequested, newMonitored);
return { ...s, mbidSet: newSet, requestedSet: newRequested, monitoredSet: newMonitored };
}); });
} }
@@ -142,17 +161,44 @@ function createLibraryStore() {
if (s.mbidSet.has(mbid.toLowerCase())) { if (s.mbidSet.has(mbid.toLowerCase())) {
return s; return s;
} }
const newSet = new Set(s.requestedSet); const lower = mbid.toLowerCase();
newSet.add(mbid.toLowerCase()); const newRequested = new Set(s.requestedSet);
persistState(s.mbidSet, newSet); newRequested.add(lower);
return { ...s, requestedSet: newSet }; const newMonitored = new Set(s.monitoredSet);
newMonitored.delete(lower);
persistState(s.mbidSet, newRequested, newMonitored);
return { ...s, requestedSet: newRequested, monitoredSet: newMonitored };
}); });
} }
function isRequested(mbid: string | null | undefined): boolean { function isRequested(mbid: string | null | undefined): boolean {
if (!mbid) return false; if (!mbid) return false;
const lower = mbid.toLowerCase();
const state = get({ subscribe }); const state = get({ subscribe });
return state.requestedSet.has(mbid.toLowerCase()) && !state.mbidSet.has(mbid.toLowerCase()); return state.requestedSet.has(lower) && !state.mbidSet.has(lower);
}
function isMonitored(mbid: string | null | undefined): boolean {
if (!mbid) return false;
const state = get({ subscribe });
return (
state.monitoredSet.has(mbid.toLowerCase()) &&
!state.mbidSet.has(mbid.toLowerCase()) &&
!state.requestedSet.has(mbid.toLowerCase())
);
}
function setMonitored(mbid: string, monitored: boolean) {
update((s) => {
const newMonitored = new Set(s.monitoredSet);
if (monitored) {
newMonitored.add(mbid.toLowerCase());
} else {
newMonitored.delete(mbid.toLowerCase());
}
persistState(s.mbidSet, s.requestedSet, newMonitored);
return { ...s, monitoredSet: newMonitored };
});
} }
async function refresh() { async function refresh() {
@@ -179,6 +225,8 @@ function createLibraryStore() {
removeMbid, removeMbid,
isRequested, isRequested,
addRequested, addRequested,
isMonitored,
setMonitored,
updateCacheTTL: cache.updateTTL updateCacheTTL: cache.updateTTL
}; };
} }
+12
View File
@@ -20,6 +20,7 @@ export type Album = {
musicbrainz_id: string; musicbrainz_id: string;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
cover_url?: string | null; cover_url?: string | null;
album_thumb_url?: string | null; album_thumb_url?: string | null;
album_back_url?: string | null; album_back_url?: string | null;
@@ -63,6 +64,7 @@ export type SuggestResult = {
musicbrainz_id: string; musicbrainz_id: string;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
disambiguation?: string | null; disambiguation?: string | null;
score: number; score: number;
}; };
@@ -111,6 +113,7 @@ export type ReleaseGroup = {
first_release_date?: string; first_release_date?: string;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
}; };
export type ExternalLink = { export type ExternalLink = {
@@ -207,6 +210,7 @@ export type AlbumInfo = {
total_length?: number | null; total_length?: number | null;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
cover_url?: string | null; cover_url?: string | null;
album_thumb_url?: string | null; album_thumb_url?: string | null;
album_back_url?: string | null; album_back_url?: string | null;
@@ -229,6 +233,7 @@ export type AlbumBasicInfo = {
disambiguation?: string | null; disambiguation?: string | null;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
cover_url?: string | null; cover_url?: string | null;
album_thumb_url?: string | null; album_thumb_url?: string | null;
}; };
@@ -274,6 +279,7 @@ export type HomeArtist = {
image_url: string | null; image_url: string | null;
listen_count: number | null; listen_count: number | null;
in_library: boolean; in_library: boolean;
monitored?: boolean;
}; };
export type HomeAlbum = { export type HomeAlbum = {
@@ -286,6 +292,7 @@ export type HomeAlbum = {
listen_count: number | null; listen_count: number | null;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
}; };
export type HomeTrack = { export type HomeTrack = {
@@ -506,6 +513,7 @@ export type SimilarArtist = {
name: string; name: string;
listen_count: number; listen_count: number;
in_library: boolean; in_library: boolean;
monitored?: boolean;
image_url?: string | null; image_url?: string | null;
}; };
@@ -552,6 +560,7 @@ export type TopAlbum = {
listen_count: number; listen_count: number;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
cover_url?: string | null; cover_url?: string | null;
}; };
@@ -569,6 +578,7 @@ export type DiscoveryAlbum = {
year?: number | null; year?: number | null;
in_library: boolean; in_library: boolean;
requested?: boolean; requested?: boolean;
monitored?: boolean;
cover_url?: string | null; cover_url?: string | null;
}; };
@@ -587,6 +597,7 @@ export type DiscoverQueueItemLight = {
recommendation_reason: string; recommendation_reason: string;
is_wildcard: boolean; is_wildcard: boolean;
in_library: boolean; in_library: boolean;
monitored?: boolean;
}; };
export type DiscoverQueueEnrichment = { export type DiscoverQueueEnrichment = {
@@ -747,6 +758,7 @@ export type RequestHistoryItem = {
completed_at?: string | null; completed_at?: string | null;
status: string; status: string;
in_library: boolean; in_library: boolean;
monitored?: boolean;
}; };
export type ActiveRequestsResponse = { export type ActiveRequestsResponse = {
+7
View File
@@ -0,0 +1,7 @@
import { libraryStore } from '$lib/stores/library';
import { api } from '$lib/api/client';
export async function toggleAlbumMonitored(mbid: string, monitored: boolean): Promise<void> {
await api.global.put(`/api/v1/albums/${mbid}/monitor`, { monitored });
libraryStore.setMonitored(mbid, monitored);
}
@@ -59,15 +59,19 @@
loadingTracks={state.loadingTracks} loadingTracks={state.loadingTracks}
inLibrary={state.inLibrary} inLibrary={state.inLibrary}
isRequested={state.isRequested} isRequested={state.isRequested}
albumMonitored={state.albumMonitored}
requesting={state.requesting} requesting={state.requesting}
refreshing={state.refreshing} refreshing={state.refreshing}
pollingForSources={state.pollingForSources} pollingForSources={state.pollingForSources}
lidarrConfigured={$integrationStore.lidarr} lidarrConfigured={$integrationStore.lidarr}
monitorToggleLoading={state.monitorToggleLoading}
artistMonitored={state.artistMonitored} artistMonitored={state.artistMonitored}
artistInLidarr={state.artistInLidarr}
onrequest={state.handleRequest} onrequest={state.handleRequest}
ondelete={state.handleDeleteClick} ondelete={state.handleDeleteClick}
onrefresh={state.refreshAll} onrefresh={state.refreshAll}
onartistclick={state.goToArtist} onartistclick={state.goToArtist}
ontogglemonitored={state.handleToggleMonitored}
/> />
{#if state.loadingTracks} {#if state.loadingTracks}
@@ -5,7 +5,7 @@
import AlbumImage from '$lib/components/AlbumImage.svelte'; import AlbumImage from '$lib/components/AlbumImage.svelte';
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte'; import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
import { formatTotalDuration } from '$lib/utils/formatting'; import { formatTotalDuration } from '$lib/utils/formatting';
import { Check, Trash2, Clock, Plus, RefreshCw } from 'lucide-svelte'; import { Check, Trash2, Clock, Plus, RefreshCw, Bookmark } from 'lucide-svelte';
interface Props { interface Props {
album: AlbumBasicInfo; album: AlbumBasicInfo;
@@ -13,16 +13,20 @@
loadingTracks: boolean; loadingTracks: boolean;
inLibrary: boolean; inLibrary: boolean;
isRequested: boolean; isRequested: boolean;
albumMonitored: boolean;
requesting: boolean; requesting: boolean;
refreshing: boolean; refreshing: boolean;
pollingForSources: boolean; pollingForSources: boolean;
lidarrConfigured: boolean; lidarrConfigured: boolean;
monitorToggleLoading?: boolean;
artistMonitored?: boolean; artistMonitored?: boolean;
artistInLidarr?: boolean;
onrequest: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void; onrequest: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void;
ondelete: () => void; ondelete: () => void;
onrefresh: () => void; onrefresh: () => void;
onartistclick: () => void; onartistclick: () => void;
ontogglemonitored: (monitored: boolean) => void;
} }
let { let {
@@ -31,15 +35,19 @@
loadingTracks, loadingTracks,
inLibrary, inLibrary,
isRequested, isRequested,
albumMonitored,
requesting, requesting,
refreshing, refreshing,
pollingForSources, pollingForSources,
lidarrConfigured, lidarrConfigured,
monitorToggleLoading = false,
artistMonitored = false, artistMonitored = false,
artistInLidarr = false,
onrequest, onrequest,
ondelete, ondelete,
onrefresh, onrefresh,
onartistclick onartistclick,
ontogglemonitored
}: Props = $props(); }: Props = $props();
let monitorArtist = $state(false); let monitorArtist = $state(false);
@@ -59,6 +67,8 @@
? getApiUrl(`/api/v1/covers/release-group/${album.musicbrainz_id}?size=250`) ? getApiUrl(`/api/v1/covers/release-group/${album.musicbrainz_id}?size=250`)
: null) : null)
); );
const showMonitorToggle = $derived(lidarrConfigured && artistInLidarr);
</script> </script>
<div class="album-hero group relative overflow-hidden rounded-2xl transition-all duration-500"> <div class="album-hero group relative overflow-hidden rounded-2xl transition-all duration-500">
@@ -72,7 +82,7 @@
/> />
<div class="relative z-10 flex flex-col lg:flex-row gap-6 lg:gap-8 p-4 sm:p-6 lg:p-8"> <div class="relative z-10 flex flex-col lg:flex-row gap-6 lg:gap-8 p-4 sm:p-6 lg:p-8">
{#if (inLibrary || isRequested) && lidarrConfigured} {#if (inLibrary || isRequested || albumMonitored) && lidarrConfigured}
<button <button
class="absolute top-3 right-3 btn btn-sm btn-ghost btn-circle z-20" class="absolute top-3 right-3 btn btn-sm btn-ghost btn-circle z-20"
onclick={onrefresh} onclick={onrefresh}
@@ -223,6 +233,23 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if showMonitorToggle}
<label class="flex items-center gap-2 pt-2 cursor-pointer">
<Bookmark class="h-4 w-4 text-base-content/70" />
<span class="text-sm text-base-content/70">Monitor</span>
{#if monitorToggleLoading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<input
type="checkbox"
checked={albumMonitored}
onchange={() => ontogglemonitored(!albumMonitored)}
class="toggle toggle-sm toggle-accent"
/>
{/if}
</label>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -3,6 +3,7 @@ import { artistHref } from '$lib/utils/entityRoutes';
import type { AlbumBasicInfo, YouTubeTrackLink, YouTubeLink, YouTubeQuotaStatus } from '$lib/types'; import type { AlbumBasicInfo, YouTubeTrackLink, YouTubeLink, YouTubeQuotaStatus } from '$lib/types';
import { compareDiscTrack, getDiscTrackKey } from '$lib/player/queueHelpers'; import { compareDiscTrack, getDiscTrackKey } from '$lib/player/queueHelpers';
import { requestAlbum } from '$lib/utils/albumRequest'; import { requestAlbum } from '$lib/utils/albumRequest';
import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
export interface EventHandlerDeps { export interface EventHandlerDeps {
getAlbum: () => AlbumBasicInfo | null; getAlbum: () => AlbumBasicInfo | null;
@@ -18,6 +19,7 @@ export interface EventHandlerDeps {
setShowDeleteModal: (v: boolean) => void; setShowDeleteModal: (v: boolean) => void;
setShowArtistRemovedModal: (v: boolean) => void; setShowArtistRemovedModal: (v: boolean) => void;
setRemovedArtistName: (v: string) => void; setRemovedArtistName: (v: string) => void;
setMonitorToggleLoading: (v: boolean) => void;
setToast: (msg: string, type: 'success' | 'error' | 'info' | 'warning') => void; setToast: (msg: string, type: 'success' | 'error' | 'info' | 'warning') => void;
setShowToast: (v: boolean) => void; setShowToast: (v: boolean) => void;
onRequestSuccess?: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void; onRequestSuccess?: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void;
@@ -85,6 +87,7 @@ export function createEventHandlers(deps: EventHandlerDeps) {
if (album) { if (album) {
album.in_library = false; album.in_library = false;
album.requested = false; album.requested = false;
album.monitored = false;
deps.setAlbum(album); deps.setAlbum(album);
deps.albumBasicCacheSet(album, deps.getAlbumId()); deps.albumBasicCacheSet(album, deps.getAlbumId());
} }
@@ -96,6 +99,26 @@ export function createEventHandlers(deps: EventHandlerDeps) {
} }
} }
async function handleToggleMonitored(monitored: boolean): Promise<void> {
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 { function goToArtist(): void {
const album = deps.getAlbum(); const album = deps.getAlbum();
// eslint-disable-next-line svelte/no-navigation-without-resolve -- artistHref uses resolve() internally // eslint-disable-next-line svelte/no-navigation-without-resolve -- artistHref uses resolve() internally
@@ -110,6 +133,7 @@ export function createEventHandlers(deps: EventHandlerDeps) {
handleRequest, handleRequest,
handleDeleteClick, handleDeleteClick,
handleDeleted, handleDeleted,
handleToggleMonitored,
goToArtist goToArtist
}; };
} }
@@ -108,6 +108,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
let playlistModalRef = $state<{ open: (tracks: QueueItem[]) => void } | null>(null); let playlistModalRef = $state<{ open: (tracks: QueueItem[]) => void } | null>(null);
let abortController: AbortController | null = null; let abortController: AbortController | null = null;
let refreshing = $state(false); let refreshing = $state(false);
let monitorToggleLoading = $state(false);
let pollingForSources = $state(false); let pollingForSources = $state(false);
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
let artistInLidarr = $state(false); let artistInLidarr = $state(false);
@@ -130,6 +131,17 @@ export function createAlbumPageState(albumIdGetter: () => string) {
const isRequested = $derived( const isRequested = $derived(
!!(album && !inLibrary && (album.requested || libraryStore.isRequested(album.musicbrainz_id))) !!(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() { function resetState() {
if (abortController) { if (abortController) {
@@ -562,6 +574,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
setShowDeleteModal: (v) => (showDeleteModal = v), setShowDeleteModal: (v) => (showDeleteModal = v),
setShowArtistRemovedModal: (v) => (showArtistRemovedModal = v), setShowArtistRemovedModal: (v) => (showArtistRemovedModal = v),
setRemovedArtistName: (v) => (removedArtistName = v), setRemovedArtistName: (v) => (removedArtistName = v),
setMonitorToggleLoading: (v) => (monitorToggleLoading = v),
setToast: (msg, type) => { setToast: (msg, type) => {
toastMessage = msg; toastMessage = msg;
toastType = type; toastType = type;
@@ -800,6 +813,12 @@ export function createAlbumPageState(albumIdGetter: () => string) {
get isRequested() { get isRequested() {
return isRequested; return isRequested;
}, },
get isMonitored() {
return isMonitored;
},
get albumMonitored() {
return albumMonitored;
},
get artistInLidarr() { get artistInLidarr() {
return artistInLidarr; return artistInLidarr;
}, },
@@ -809,6 +828,9 @@ export function createAlbumPageState(albumIdGetter: () => string) {
get refreshing() { get refreshing() {
return refreshing; return refreshing;
}, },
get monitorToggleLoading() {
return monitorToggleLoading;
},
get pollingForSources() { get pollingForSources() {
return pollingForSources; return pollingForSources;
}, },