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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 772 additions and 187 deletions

View file

@ -1,4 +1,4 @@
from typing import Optional
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
from core.exceptions import ClientDisconnectedError
@ -11,9 +11,12 @@ from services.album_enrichment_service import AlbumEnrichmentService
from services.navidrome_library_service import NavidromeLibraryService
from infrastructure.validators import is_unknown_mbid
from infrastructure.degradation import try_get_degradation_context
from infrastructure.msgspec_fastapi import MsgSpecRoute
from infrastructure.msgspec_fastapi import AppStruct, MsgSpecBody, MsgSpecRoute
import msgspec.structs
import msgspec
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/albums", tags=["album"])
@ -151,6 +154,44 @@ async def get_more_by_artist(
return await discovery_service.get_more_by_artist(artist_id, album_id, count)
class MonitorRequest(AppStruct):
monitored: bool
@router.put("/{album_id}/monitor")
async def set_album_monitored(
album_id: str,
body: MonitorRequest = MsgSpecBody(MonitorRequest),
album_service: AlbumService = Depends(get_album_service),
):
if is_unknown_mbid(album_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid or unknown album ID: {album_id}"
)
try:
success = await album_service.set_album_monitored(album_id, body.monitored)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Album not found in Lidarr: {album_id}"
)
return {"success": True}
except HTTPException:
raise
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid album request"
)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to update monitoring status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update monitoring status"
)
@router.get("/{album_id}/lastfm", response_model=LastFmAlbumEnrichment)
async def get_album_lastfm_enrichment(
album_id: str,

View file

@ -112,11 +112,12 @@ async def get_library_stats(
async def get_library_mbids(
library_service: LibraryService = Depends(get_library_service)
):
mbids, requested = await asyncio.gather(
mbids, requested, monitored = await asyncio.gather(
library_service.get_library_mbids(),
library_service.get_requested_mbids(),
library_service.get_monitored_mbids(),
)
return LibraryMbidsResponse(mbids=mbids, requested_mbids=requested)
return LibraryMbidsResponse(mbids=mbids, requested_mbids=requested, monitored_mbids=monitored)
@router.get("/grouped", response_model=LibraryGroupedResponse)

View file

@ -16,6 +16,7 @@ class AlbumBasicInfo(AppStruct):
disambiguation: str | None = None
in_library: bool = False
requested: bool = False
monitored: bool = False
cover_url: str | None = None
album_thumb_url: str | None = None

View file

@ -24,6 +24,7 @@ class DiscoverQueueItemLight(AppStruct):
cover_url: str | None = None
is_wildcard: bool = False
in_library: bool = False
monitored: bool = False
class DiscoverQueueEnrichment(AppStruct):

View file

@ -41,6 +41,7 @@ class TopAlbum(AppStruct):
listen_count: int = 0
in_library: bool = False
requested: bool = False
monitored: bool = False
cover_url: str | None = None
@ -58,6 +59,7 @@ class DiscoveryAlbum(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
monitored: bool = False
cover_url: str | None = None

View file

@ -22,6 +22,7 @@ class HomeAlbum(AppStruct):
listen_count: int | None = None
in_library: bool = False
requested: bool = False
monitored: bool = False
source: str | None = None

View file

@ -73,6 +73,7 @@ class SyncLibraryResponse(AppStruct):
class LibraryMbidsResponse(AppStruct):
mbids: list[str] = []
requested_mbids: list[str] = []
monitored_mbids: list[str] = []
class LibraryGroupedResponse(AppStruct):

View file

@ -64,6 +64,7 @@ class SuggestResult(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
monitored: bool = False
disambiguation: str | None = None
score: int = 0

View file

@ -154,6 +154,10 @@ def lidarr_requested_mbids_key() -> str:
return f"{LIDARR_REQUESTED_PREFIX}_mbids"
def lidarr_monitored_mbids_key() -> str:
return f"{LIDARR_PREFIX}monitored_mbids"
def lidarr_status_key() -> str:
return f"{LIDARR_PREFIX}status"

View file

@ -18,6 +18,8 @@ def _decode_json(text: str) -> Any:
class DiskMetadataCache:
_CACHE_VERSION = "v3"
def __init__(
self,
base_path: Path,
@ -57,7 +59,7 @@ class DiskMetadataCache:
@staticmethod
def _cache_hash(identifier: str) -> str:
return hashlib.sha1(identifier.encode()).hexdigest()
return hashlib.sha1(f"{DiskMetadataCache._CACHE_VERSION}:{identifier}".encode()).hexdigest()
@staticmethod
def _meta_path(file_path: Path) -> Path:

View file

@ -26,6 +26,7 @@ class AlbumInfo(AppStruct):
total_length: int | None = None
in_library: bool = False
requested: bool = False
monitored: bool = False
cover_url: str | None = None
album_thumb_url: str | None = None
album_back_url: str | None = None

View file

@ -26,6 +26,7 @@ class ReleaseItem(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
monitored: bool = False
class ArtistInfo(AppStruct):

View file

@ -9,6 +9,7 @@ class SearchResult(AppStruct):
year: int | None = None
in_library: bool = False
requested: bool = False
monitored: bool = False
cover_url: str | None = None
album_thumb_url: str | None = None
thumb_url: str | None = None

View file

@ -296,6 +296,20 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
logger.error(f"Failed to delete album {album_id}: {e}")
raise
async def set_monitored(self, album_mbid: str, monitored: bool) -> bool:
lidarr_album = await self._get_album_by_foreign_id(album_mbid)
if not lidarr_album:
return False
album_id = lidarr_album.get("id")
artist_mbid = (lidarr_album.get("artist", {}).get("foreignArtistId") or "")
if not album_id:
return False
lock = _get_artist_lock(artist_mbid)
async with lock:
await self._update_album(album_id, {"monitored": monitored})
await self._invalidate_album_list_caches()
return True
async def add_album(self, musicbrainz_id: str, artist_repo) -> dict:
t0 = time.monotonic()
if not musicbrainz_id or not isinstance(musicbrainz_id, str):

View file

@ -5,7 +5,7 @@ import time
from typing import Any, Optional
from core.config import Settings
from core.exceptions import ExternalServiceError
from infrastructure.cache.cache_keys import lidarr_raw_albums_key, lidarr_requested_mbids_key, LIDARR_PREFIX
from infrastructure.cache.cache_keys import lidarr_raw_albums_key, lidarr_requested_mbids_key, lidarr_monitored_mbids_key, LIDARR_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.http.deduplication import get_deduplicator
from infrastructure.resilience.retry import with_retry, CircuitBreaker
@ -141,6 +141,7 @@ class LidarrBase:
await self._cache.delete(lidarr_raw_albums_key())
await self._cache.clear_prefix(f"{LIDARR_PREFIX}library:")
await self._cache.delete(lidarr_requested_mbids_key())
await self._cache.delete(lidarr_monitored_mbids_key())
async def _post(self, endpoint: str, data: dict[str, Any]) -> Any:
return await self._request("POST", endpoint, json_data=data)

View file

@ -9,6 +9,7 @@ from infrastructure.cache.cache_keys import (
lidarr_artist_mbids_key,
lidarr_library_grouped_key,
lidarr_requested_mbids_key,
lidarr_monitored_mbids_key,
)
from .base import LidarrBase
@ -31,11 +32,17 @@ class LidarrLibraryRepository(LidarrBase):
for item in data:
is_monitored = item.get("monitored", False)
statistics = item.get("statistics", {})
has_files = statistics.get("trackFileCount", 0) > 0
if not is_monitored and not include_unmonitored:
filtered_count += 1
continue
if not has_files:
filtered_count += 1
continue
artist_data = item.get("artist", {})
artist = artist_data.get("artistName", "Unknown")
artist_mbid = artist_data.get("foreignArtistId")
@ -92,11 +99,17 @@ class LidarrLibraryRepository(LidarrBase):
for item in albums_data:
is_monitored = item.get("monitored", False)
statistics = item.get("statistics", {})
has_files = statistics.get("trackFileCount", 0) > 0
if not is_monitored and not include_unmonitored:
filtered_count += 1
continue
if not has_files:
filtered_count += 1
continue
artist_data = item.get("artist", {})
artist_mbid = artist_data.get("foreignArtistId")
artist_name = artist_data.get("artistName", "Unknown")
@ -139,6 +152,10 @@ class LidarrLibraryRepository(LidarrBase):
grouped: dict[str, list[LibraryGroupedAlbum]] = {}
for item in data:
statistics = item.get("statistics", {})
if statistics.get("trackFileCount", 0) == 0:
continue
artist = item.get("artist", {}).get("artistName", "Unknown")
title = item.get("title")
year = None
@ -182,9 +199,6 @@ class LidarrLibraryRepository(LidarrBase):
data = await self._get_all_albums_raw()
ids: set[str] = set()
for item in data:
if not item.get("monitored", False):
continue
statistics = item.get("statistics", {})
track_file_count = statistics.get("trackFileCount", 0)
if track_file_count == 0:
@ -212,7 +226,9 @@ class LidarrLibraryRepository(LidarrBase):
data = await self._get("/api/v1/artist")
ids: set[str] = set()
for item in data:
if not item.get("monitored", False):
is_monitored = item.get("monitored", False)
has_files = item.get("statistics", {}).get("trackFileCount", 0) > 0
if not is_monitored and not has_files:
continue
mbid = item.get("foreignArtistId") or item.get("mbId")
if isinstance(mbid, str):
@ -232,7 +248,7 @@ class LidarrLibraryRepository(LidarrBase):
async def get_monitored_no_files_mbids(self) -> set[str]:
"""Return monitored Lidarr albums that have no downloaded track files."""
cache_key = lidarr_requested_mbids_key()
cache_key = lidarr_monitored_mbids_key()
cached_result = await self._cache.get(cache_key)
if cached_result is not None:

View file

@ -49,6 +49,9 @@ class LidarrRepositoryProtocol(Protocol):
async def get_requested_mbids(self) -> set[str]:
...
async def get_monitored_no_files_mbids(self) -> set[str]:
...
async def delete_album(self, album_id: int, delete_files: bool = False) -> bool:
...
@ -92,3 +95,6 @@ class LidarrRepositoryProtocol(Protocol):
self, artist_mbid: str, *, monitored: bool, monitor_new_items: str = "none",
) -> dict[str, Any]:
...
async def set_monitored(self, album_mbid: str, monitored: bool) -> bool:
...

View file

@ -8,7 +8,7 @@ from repositories.protocols import LidarrRepositoryProtocol, MusicBrainzReposito
from services.preferences_service import PreferencesService
from services.album_utils import parse_year, find_primary_release, get_ranked_releases, extract_artist_info, extract_tracks, extract_label, build_album_basic_info, lidarr_to_basic_info, mb_to_basic_info
from infrastructure.persistence import LibraryDB
from infrastructure.cache.cache_keys import ALBUM_INFO_PREFIX, LIDARR_ALBUM_DETAILS_PREFIX
from infrastructure.cache.cache_keys import ALBUM_INFO_PREFIX, LIDARR_ALBUM_DETAILS_PREFIX, ARTIST_INFO_PREFIX, LIDARR_ARTIST_ALBUMS_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.cache.disk_cache import DiskMetadataCache
from infrastructure.cover_urls import prefer_release_group_cover_url
@ -166,10 +166,10 @@ class AlbumService:
await self._disk_cache.set_album(release_group_id, album_info, is_monitored=album_info.in_library, ttl_seconds=ttl if not album_info.in_library else None)
def _check_lidarr_in_library(self, lidarr_album: dict | None) -> bool:
if lidarr_album and lidarr_album.get("monitored", False):
statistics = lidarr_album.get("statistics", {})
return statistics.get("trackFileCount", 0) > 0
return False
if lidarr_album is None:
return False
statistics = lidarr_album.get("statistics", {})
return statistics.get("trackFileCount", 0) > 0
async def warm_full_album_cache(self, release_group_id: str) -> None:
"""Fire-and-forget: populate the full album_info cache if missing."""
@ -192,7 +192,48 @@ class AlbumService:
return await self.get_album_info(release_group_id)
async def get_album_info(self, release_group_id: str, monitored_mbids: set[str] = None) -> AlbumInfo:
async def set_album_monitored(self, release_group_id: str, monitored: bool) -> bool:
try:
release_group_id = validate_mbid(release_group_id, "album")
except ValueError as e:
logger.error(f"Invalid album MBID: {e}")
raise
success = await self._lidarr_repo.set_monitored(release_group_id, monitored)
if success:
await self._cache.delete(f"{ALBUM_INFO_PREFIX}{release_group_id}")
await self._cache.delete(f"{LIDARR_ALBUM_DETAILS_PREFIX}{release_group_id}")
await self._disk_cache.delete_album(release_group_id)
try:
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id)
if lidarr_album:
artist_data = lidarr_album.get("artist", {})
artist_mbid = artist_data.get("foreignArtistId")
if artist_mbid:
await self._cache.delete(f"{ARTIST_INFO_PREFIX}{artist_mbid}")
await self._cache.delete(f"{LIDARR_ARTIST_ALBUMS_PREFIX}{artist_mbid}")
await self._disk_cache.delete_artist(artist_mbid)
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to invalidate artist caches for {release_group_id}: {e}")
try:
existing = await self._library_db.get_album_by_mbid(release_group_id)
if existing:
album_data: dict = {
"mbid": release_group_id,
"monitored": monitored,
"artist_mbid": existing.get("artist_mbid"),
"artist_name": existing.get("artist_name"),
"title": existing.get("title"),
"year": existing.get("year"),
"cover_url": existing.get("cover_url"),
"date_added": existing.get("date_added"),
}
await self._library_db.upsert_album(album_data)
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to update library_db for {release_group_id}: {e}")
return success
async def get_album_info(self, release_group_id: str, library_mbids: set[str] = None) -> AlbumInfo:
try:
release_group_id = validate_mbid(release_group_id, "album")
except ValueError as e:
@ -215,7 +256,7 @@ class AlbumService:
future: asyncio.Future[AlbumInfo] = loop.create_future()
self._album_in_flight[release_group_id] = future
try:
album_info = await self._do_get_album_info(release_group_id, cache_key, monitored_mbids)
album_info = await self._do_get_album_info(release_group_id, cache_key, library_mbids)
if not future.done():
future.set_result(album_info)
return album_info
@ -232,14 +273,16 @@ class AlbumService:
raise ResourceNotFoundError(f"Failed to get album info: {e}")
async def _do_get_album_info(
self, release_group_id: str, cache_key: str, monitored_mbids: set[str] | None
self, release_group_id: str, cache_key: str, library_mbids: set[str] | None
) -> AlbumInfo:
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None
in_library = self._check_lidarr_in_library(lidarr_album)
if in_library and lidarr_album:
album_info = await self._build_album_from_lidarr(release_group_id, lidarr_album)
else:
album_info = await self._build_album_from_musicbrainz(release_group_id, monitored_mbids)
album_info = await self._build_album_from_musicbrainz(release_group_id, library_mbids)
if lidarr_album is not None:
album_info.monitored = lidarr_album.get("monitored", False)
album_info = await self._apply_audiodb_album_images(
album_info, release_group_id, album_info.artist_name, album_info.title,
allow_fetch=True, is_monitored=album_info.in_library,
@ -286,14 +329,21 @@ class AlbumService:
disambiguation=cached_album_info.disambiguation,
in_library=cached_album_info.in_library,
requested=is_requested and not cached_album_info.in_library,
monitored=cached_album_info.monitored,
cover_url=cached_album_info.cover_url,
album_thumb_url=album_thumb,
)
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None
in_library = self._check_lidarr_in_library(lidarr_album)
if lidarr_album and lidarr_album.get("monitored", False):
is_monitored = False
if lidarr_album is not None:
is_monitored = lidarr_album.get("monitored", False)
if lidarr_album:
basic = AlbumBasicInfo(**lidarr_to_basic_info(lidarr_album, release_group_id, in_library, is_requested=is_requested))
basic.monitored = is_monitored
if not basic.album_thumb_url:
basic.album_thumb_url = await self._get_audiodb_album_thumb(
release_group_id, basic.artist_name, basic.title,
@ -305,6 +355,7 @@ class AlbumService:
cached_album = await self._library_db.get_album_by_mbid(release_group_id)
in_library = cached_album is not None
basic = AlbumBasicInfo(**mb_to_basic_info(release_group, release_group_id, in_library, is_requested))
basic.monitored = is_monitored
basic.album_thumb_url = await self._get_audiodb_album_thumb(
release_group_id, basic.artist_name, basic.title,
allow_fetch=False,
@ -338,7 +389,7 @@ class AlbumService:
)
lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None
in_library = lidarr_album is not None and lidarr_album.get("monitored", False)
in_library = self._check_lidarr_in_library(lidarr_album)
if in_library and lidarr_album:
album_id = lidarr_album.get("id")
@ -428,9 +479,9 @@ class AlbumService:
return rg_result
async def _check_in_library(self, release_group_id: str, monitored_mbids: set[str] = None) -> bool:
if monitored_mbids is not None:
return release_group_id.lower() in monitored_mbids
async def _check_in_library(self, release_group_id: str, library_mbids: set[str] = None) -> bool:
if library_mbids is not None:
return release_group_id.lower() in library_mbids
library_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True)
return release_group_id.lower() in library_mbids
@ -557,13 +608,14 @@ class AlbumService:
total_tracks=len(tracks),
total_length=total_length if total_length > 0 else None,
in_library=True,
monitored=lidarr_album.get("monitored", False),
cover_url=cover_url,
)
async def _build_album_from_musicbrainz(
self,
release_group_id: str,
monitored_mbids: set[str] = None
library_mbids: set[str] = None
) -> AlbumInfo:
cached_album = await self._library_db.get_album_by_mbid(release_group_id)
in_library = cached_album is not None
@ -573,7 +625,7 @@ class AlbumService:
artist_name, artist_id = extract_artist_info(release_group)
if not in_library:
in_library = await self._check_in_library(release_group_id, monitored_mbids)
in_library = await self._check_in_library(release_group_id, library_mbids)
basic_info = self._build_basic_info(
release_group, release_group_id, artist_name, artist_id, in_library

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"),
"in_library": in_library,
"requested": is_requested and not in_library,
"monitored": lidarr_album.get("monitored", False),
"cover_url": lidarr_album.get("cover_url"),
}
@ -146,5 +147,6 @@ def mb_to_basic_info(release_group: dict, release_group_id: str, in_library: boo
"disambiguation": release_group.get("disambiguation"),
"in_library": in_library,
"requested": is_requested and not in_library,
"monitored": False,
"cover_url": None,
}

View file

@ -218,8 +218,15 @@ class ArtistService:
library_album_mbids: dict[str, Any] | None,
) -> ArtistInfo:
lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id) if self._lidarr_repo.is_configured() else None
in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False)
if in_library and lidarr_artist:
use_lidarr_data = False
if lidarr_artist is not None:
artist_monitored = lidarr_artist.get("monitored", False)
has_albums_with_files = any(
a.get("statistics", {}).get("trackFileCount", 0) > 0
for a in (lidarr_artist.get("albums") or [])
)
use_lidarr_data = artist_monitored or has_albums_with_files
if use_lidarr_data and lidarr_artist:
artist_info = await self._build_artist_from_lidarr(artist_id, lidarr_artist, library_album_mbids)
else:
artist_info = await self._build_artist_from_musicbrainz(artist_id, library_artist_mbids, library_album_mbids)
@ -390,11 +397,11 @@ class ArtistService:
include_extended: bool = True,
include_releases: bool = True,
) -> ArtistInfo:
mb_artist, library_mbids, album_mbids, requested_mbids = await self._fetch_artist_data(
mb_artist, library_mbids, album_mbids, requested_mbids, monitored_mbids = await self._fetch_artist_data(
artist_id, library_artist_mbids, library_album_mbids
)
in_library = artist_id.lower() in library_mbids
albums, singles, eps = (await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids)) if include_releases else ([], [], [])
albums, singles, eps = (await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids, monitored_mbids)) if include_releases else ([], [], [])
description, image = (await self._fetch_wikidata_info(mb_artist)) if include_extended else (None, None)
info = build_base_artist_info(
mb_artist, artist_id, in_library,
@ -440,9 +447,10 @@ class ArtistService:
if not self._lidarr_repo.is_configured():
return
try:
library_mbids, requested_mbids, artist_mbids = await asyncio.gather(
library_mbids, requested_mbids, monitored_mbids, artist_mbids = await asyncio.gather(
self._lidarr_repo.get_library_mbids(include_release_ids=False),
self._lidarr_repo.get_requested_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
self._lidarr_repo.get_artist_mbids(),
)
for release_list in (artist_info.albums, artist_info.singles, artist_info.eps):
@ -452,6 +460,7 @@ class ArtistService:
continue
rg.in_library = rg_id in library_mbids
rg.requested = rg_id in requested_mbids and not rg.in_library
rg.monitored = rg_id in monitored_mbids
mbid_lower = artist_info.musicbrainz_id.lower()
is_in_artist_mbids = mbid_lower in artist_mbids
artist_info.in_library = is_in_artist_mbids
@ -530,12 +539,20 @@ class ArtistService:
) -> ArtistReleases:
try:
lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id)
in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False)
has_lidarr_data = False
if lidarr_artist is not None:
artist_monitored = lidarr_artist.get("monitored", False)
has_albums_with_files = any(
a.get("statistics", {}).get("trackFileCount", 0) > 0
for a in (lidarr_artist.get("albums") or [])
)
has_lidarr_data = artist_monitored or has_albums_with_files
album_mbids, requested_mbids, cache_mbids = await asyncio.gather(
album_mbids, requested_mbids, cache_mbids, monitored_mbids = await asyncio.gather(
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_requested_mbids(),
self._get_library_cache_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
)
album_mbids = album_mbids | cache_mbids
@ -543,7 +560,7 @@ class ArtistService:
included_primary_types = set(t.lower() for t in prefs.primary_types)
included_secondary_types = set(t.lower() for t in prefs.secondary_types)
if in_library:
if has_lidarr_data:
if offset == 0:
lidarr_albums = await self._lidarr_repo.get_artist_albums(artist_id)
albums, singles, eps = self._categorize_lidarr_albums(lidarr_albums, album_mbids, requested_mbids=requested_mbids)
@ -567,7 +584,7 @@ class ArtistService:
return await self._filter_aware_release_page(
artist_id, offset, limit, album_mbids, requested_mbids,
included_primary_types, included_secondary_types,
included_primary_types, included_secondary_types, monitored_mbids,
)
except Exception as e: # noqa: BLE001
logger.error(f"Error fetching releases for artist {artist_id} at offset {offset}: {e}")
@ -586,6 +603,7 @@ class ArtistService:
requested_mbids: set[str],
included_primary_types: set[str],
included_secondary_types: set[str],
monitored_mbids: set[str] | None = None,
) -> ArtistReleases:
if not included_primary_types:
return ArtistReleases(
@ -618,7 +636,7 @@ class ArtistService:
temp_artist = {"release-group-list": release_groups}
page_albums, page_singles, page_eps = categorize_release_groups(
temp_artist, album_mbids, included_primary_types,
included_secondary_types, requested_mbids,
included_secondary_types, requested_mbids, monitored_mbids,
)
for item in page_albums:
@ -669,24 +687,27 @@ class ArtistService:
artist_id: str,
library_artist_mbids: set[str] = None,
library_album_mbids: dict[str, Any] = None
) -> tuple[dict, set[str], set[str], set[str]]:
) -> tuple[dict, set[str], set[str], set[str], set[str]]:
if library_artist_mbids is not None and library_album_mbids is not None:
mb_artist = await self._mb_repo.get_artist_by_id(artist_id)
library_mbids = library_artist_mbids
album_mbids = library_album_mbids
requested_result = await asyncio.gather(
requested_result, monitored_result = await asyncio.gather(
self._lidarr_repo.get_requested_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
requested_mbids = requested_result[0] if not isinstance(requested_result[0], BaseException) else set()
if isinstance(requested_result[0], BaseException):
logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result[0]}")
requested_mbids = requested_result if not isinstance(requested_result, BaseException) else set()
monitored_mbids = monitored_result if not isinstance(monitored_result, BaseException) else set()
if isinstance(requested_result, BaseException):
logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result}")
else:
mb_artist, *lidarr_results = await asyncio.gather(
self._mb_repo.get_artist_by_id(artist_id),
self._lidarr_repo.get_artist_mbids(),
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_requested_mbids(),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
if isinstance(mb_artist, BaseException):
@ -698,16 +719,15 @@ class ArtistService:
library_mbids = lidarr_results[0] if not isinstance(lidarr_results[0], BaseException) else set()
album_mbids = lidarr_results[1] if not isinstance(lidarr_results[1], BaseException) else set()
requested_mbids = lidarr_results[2] if not isinstance(lidarr_results[2], BaseException) else set()
monitored_mbids = lidarr_results[3] if not isinstance(lidarr_results[3], BaseException) else set()
# Supplement with LibraryDB so monitored albums (even with trackFileCount=0)
# are recognised as "in library", consistent with the Library page.
cache_mbids = await self._get_library_cache_mbids()
album_mbids = album_mbids | cache_mbids
if not mb_artist:
raise ResourceNotFoundError("Artist not found")
return mb_artist, library_mbids, album_mbids, requested_mbids
return mb_artist, library_mbids, album_mbids, requested_mbids, monitored_mbids
def _build_external_links(self, mb_artist: dict[str, Any]) -> list[ExternalLink]:
external_links_data = extract_external_links(mb_artist)
@ -720,7 +740,8 @@ class ArtistService:
self,
mb_artist: dict[str, Any],
album_mbids: set[str],
requested_mbids: set[str] = None
requested_mbids: set[str] = None,
monitored_mbids: set[str] = None,
) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]:
prefs = self._preferences_service.get_preferences()
included_primary_types = set(t.lower() for t in prefs.primary_types)
@ -730,7 +751,8 @@ class ArtistService:
album_mbids,
included_primary_types,
included_secondary_types,
requested_mbids or set()
requested_mbids or set(),
monitored_mbids or set(),
)
async def _fetch_wikidata_info(self, mb_artist: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:

View file

@ -106,11 +106,14 @@ def categorize_release_groups(
included_primary_types: Optional[set[str]] = None,
included_secondary_types: Optional[set[str]] = None,
requested_mbids: Optional[set[str]] = None,
monitored_mbids: Optional[set[str]] = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
if included_primary_types is None:
included_primary_types = {"album", "single", "ep", "broadcast", "other"}
if requested_mbids is None:
requested_mbids = set()
if monitored_mbids is None:
monitored_mbids = set()
albums: list[ReleaseItem] = []
singles: list[ReleaseItem] = []
eps: list[ReleaseItem] = []
@ -130,6 +133,7 @@ def categorize_release_groups(
rg_id_lower = rg_id.lower() if rg_id else ""
in_library = rg_id_lower in album_mbids if rg_id else False
requested = rg_id_lower in requested_mbids if rg_id and not in_library else False
is_monitored = rg_id_lower in monitored_mbids if rg_id and not in_library and not requested else False
rg_data = ReleaseItem(
id=rg_id,
title=rg.get("title"),
@ -137,6 +141,7 @@ def categorize_release_groups(
first_release_date=rg.get("first-release-date"),
in_library=in_library,
requested=requested,
monitored=is_monitored,
)
if date := rg_data.first_release_date:
try:
@ -182,6 +187,7 @@ def categorize_lidarr_albums(
track_file_count = album.get("track_file_count", 0)
in_library = track_file_count > 0 or (mbid_lower in _cache_mbids)
requested = mbid_lower in _requested_mbids and not in_library
is_monitored = album.get("monitored", False) and not in_library and not requested
album_data = ReleaseItem(
id=mbid,
title=album.get("title"),
@ -190,6 +196,7 @@ def categorize_lidarr_albums(
year=album.get("year"),
in_library=in_library,
requested=requested,
monitored=is_monitored,
)
if album_type == "album":
albums.append(album_data)

View file

@ -167,6 +167,13 @@ class DiscoverHomepageService:
library_mbids = await self._mbid.get_library_artist_mbids(lidarr_configured)
monitored_mbids: set[str] = set()
if lidarr_configured:
try:
monitored_mbids = await self._lidarr_repo.get_monitored_no_files_mbids()
except Exception: # noqa: BLE001
logger.debug("Failed to fetch monitored MBIDs for discover page")
seed_artists = await self._get_seed_artists(
lb_enabled, username, jf_enabled,
resolved_source=resolved_source,
@ -214,8 +221,8 @@ class DiscoverHomepageService:
tasks["jf_most_played"] = self._jf_repo.get_most_played_artists(limit=50)
if lidarr_configured:
tasks["library_artists"] = self._lidarr_repo.get_artists_from_library()
tasks["library_albums"] = self._lidarr_repo.get_library()
tasks["library_artists"] = self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
tasks["library_albums"] = self._lidarr_repo.get_library(include_unmonitored=True)
results = await self._execute_tasks(tasks)
@ -231,15 +238,15 @@ class DiscoverHomepageService:
)
await self._enrich_because_sections_audiodb(response.because_you_listen_to)
response.fresh_releases = self._build_fresh_releases(results, library_mbids)
response.fresh_releases = self._build_fresh_releases(results, library_mbids, monitored_mbids)
post_tasks: dict[str, Any] = {
"missing_essentials": self._build_missing_essentials(results, library_mbids),
"missing_essentials": self._build_missing_essentials(results, library_mbids, monitored_mbids),
"lastfm_weekly_album_chart": self._build_lastfm_weekly_album_chart(
results, library_mbids
results, library_mbids, monitored_mbids
),
"lastfm_recent_scrobbles": self._build_lastfm_recent_scrobbles(
results, library_mbids
results, library_mbids, monitored_mbids
),
}
if resolved_source == "listenbrainz" and lb_enabled and username:
@ -464,7 +471,8 @@ class DiscoverHomepageService:
return sections
def _build_fresh_releases(
self, results: dict[str, Any], library_mbids: set[str]
self, results: dict[str, Any], library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
releases = results.get("lb_fresh")
if not releases:
@ -475,16 +483,22 @@ class DiscoverHomepageService:
if isinstance(r, dict):
mbid = r.get("release_group_mbid", "")
artist_mbids = r.get("artist_mbids", [])
in_lib = mbid.lower() in library_mbids if isinstance(mbid, str) and mbid else False
is_monitored = (
not in_lib and bool(monitored_mbids) and isinstance(mbid, str) and mbid
and mbid.lower() in monitored_mbids
)
items.append(HomeAlbum(
mbid=mbid,
name=r.get("title", r.get("release_group_name", "Unknown")),
artist_name=r.get("artist_credit_name", r.get("artist_name", "")),
artist_mbid=artist_mbids[0] if artist_mbids else None,
listen_count=r.get("listen_count"),
in_library=mbid.lower() in library_mbids if isinstance(mbid, str) and mbid else False,
in_library=in_lib,
monitored=is_monitored,
))
else:
items.append(self._transformers.lb_release_to_home(r, library_mbids))
items.append(self._transformers.lb_release_to_home(r, library_mbids, monitored_mbids))
except Exception as e: # noqa: BLE001
logger.debug(f"Skipping fresh release item: {e}")
continue
@ -498,7 +512,8 @@ class DiscoverHomepageService:
)
async def _build_missing_essentials(
self, results: dict[str, Any], library_mbids: set[str]
self, results: dict[str, Any], library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
library_artists = results.get("library_artists") or []
library_albums = results.get("library_albums") or []
@ -551,6 +566,7 @@ class DiscoverHomepageService:
artist_name=rg.artist_name,
listen_count=rg.listen_count,
in_library=False,
monitored=bool(monitored_mbids) and rg_mbid.lower() in monitored_mbids,
))
artist_missing += 1
@ -904,6 +920,7 @@ class DiscoverHomepageService:
self,
results: dict[str, Any],
library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
albums = results.get("lfm_weekly_albums") or []
if not albums:
@ -914,7 +931,7 @@ class DiscoverHomepageService:
items = []
for album in albums[:20]:
home_album = self._transformers.lastfm_album_to_home(album, library_mbids)
home_album = self._transformers.lastfm_album_to_home(album, library_mbids, monitored_mbids)
if home_album and home_album.mbid:
home_album.mbid = rg_map.get(home_album.mbid, home_album.mbid)
items.append(home_album)
@ -933,6 +950,7 @@ class DiscoverHomepageService:
self,
results: dict[str, Any],
library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
tracks = results.get("lfm_recent") or []
if not tracks:
@ -944,7 +962,7 @@ class DiscoverHomepageService:
items = []
seen_album_mbids: set[str] = set()
for track in tracks[:30]:
home_album = self._transformers.lastfm_recent_to_home(track, library_mbids)
home_album = self._transformers.lastfm_recent_to_home(track, library_mbids, monitored_mbids)
if home_album and home_album.mbid:
resolved = rg_map.get(home_album.mbid, home_album.mbid)
home_album.mbid = resolved

View file

@ -223,7 +223,7 @@ class MbidResolutionService:
if not lidarr_configured:
return set()
try:
artists = await self._lidarr_repo.get_artists_from_library()
artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
return {a.get("mbid", "").lower() for a in artists if a.get("mbid")}
except Exception: # noqa: BLE001
logger.warning("Failed to fetch library artists from Lidarr")

View file

@ -109,15 +109,17 @@ class HomeChartsService:
self, genre: str, limit: int = 100, artist_offset: int = 0, album_offset: int = 0
) -> GenreDetailResponse:
lidarr_results = await asyncio.gather(
self._lidarr_repo.get_artists_from_library(),
self._lidarr_repo.get_library(),
self._lidarr_repo.get_artists_from_library(include_unmonitored=True),
self._lidarr_repo.get_library(include_unmonitored=True),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
lidarr_failed = any(isinstance(r, BaseException) for r in lidarr_results)
lidarr_failed = any(isinstance(r, BaseException) for r in lidarr_results[:2])
if lidarr_failed:
logger.warning("Lidarr unavailable for genre '%s', proceeding with MusicBrainz data only", genre)
library_artists = lidarr_results[0] if not isinstance(lidarr_results[0], BaseException) else []
library_albums = lidarr_results[1] if not isinstance(lidarr_results[1], BaseException) else []
monitored_mbids = lidarr_results[2] if not isinstance(lidarr_results[2], BaseException) else set()
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
library_album_mbids = {a.musicbrainz_id.lower() for a in library_albums if a.musicbrainz_id}
library_section = None
@ -177,6 +179,7 @@ class HomeChartsService:
image_url=None,
release_date=str(result.year) if result.year else None,
in_library=result.musicbrainz_id.lower() in library_album_mbids,
monitored=result.musicbrainz_id.lower() not in library_album_mbids and result.musicbrainz_id.lower() in monitored_mbids,
)
for result in mb_album_results
]
@ -203,7 +206,7 @@ class HomeChartsService:
if resolved == "lastfm" and self._lfm_repo:
return await self._get_trending_artists_lastfm(limit)
library_artists = await self._lidarr_repo.get_artists_from_library()
library_artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
ranges = ["this_week", "this_month", "this_year", "all_time"]
tasks = {r: self._lb_repo.get_sitewide_top_artists(range_=r, count=limit + 1) for r in ranges}
@ -249,7 +252,7 @@ class HomeChartsService:
offset=offset,
)
library_artists, lb_artists = await asyncio.gather(
self._lidarr_repo.get_artists_from_library(),
self._lidarr_repo.get_artists_from_library(include_unmonitored=True),
self._lb_repo.get_sitewide_top_artists(
range_=range_key, count=limit + 1, offset=offset
),
@ -275,15 +278,19 @@ class HomeChartsService:
if resolved == "lastfm" and self._lfm_repo:
return await self._get_popular_albums_lastfm(limit)
library_albums = await self._lidarr_repo.get_library()
library_albums = await self._lidarr_repo.get_library(include_unmonitored=True)
library_mbids = {(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id}
try:
monitored_mbids = await self._lidarr_repo.get_monitored_no_files_mbids()
except Exception: # noqa: BLE001
monitored_mbids = set()
ranges = ["this_week", "this_month", "this_year", "all_time"]
tasks = {r: self._lb_repo.get_sitewide_top_release_groups(range_=r, count=limit + 1) for r in ranges}
results = await self._execute_tasks(tasks)
response_data = {}
for r in ranges:
lb_albums = results.get(r) or []
albums = [self._transformers.lb_release_to_home(a, library_mbids) for a in lb_albums]
albums = [self._transformers.lb_release_to_home(a, library_mbids, monitored_mbids) for a in lb_albums]
featured = albums[0] if albums else None
items = albums[1:limit] if len(albums) > 1 else []
response_data[r] = PopularTimeRange(
@ -317,14 +324,17 @@ class HomeChartsService:
limit=limit,
offset=offset,
)
library_albums, lb_albums = await asyncio.gather(
self._lidarr_repo.get_library(),
library_albums, lb_albums, monitored_mbids_result = await asyncio.gather(
self._lidarr_repo.get_library(include_unmonitored=True),
self._lb_repo.get_sitewide_top_release_groups(
range_=range_key, count=limit + 1, offset=offset
),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
library_mbids = {(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id}
albums = [self._transformers.lb_release_to_home(a, library_mbids) for a in lb_albums]
monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
albums = [self._transformers.lb_release_to_home(a, library_mbids, monitored_mbids) for a in lb_albums]
has_more = len(albums) > limit
items = albums[:limit]
return PopularAlbumsRangeResponse(
@ -337,7 +347,7 @@ class HomeChartsService:
)
async def _get_trending_artists_lastfm(self, limit: int = 10) -> TrendingArtistsResponse:
library_artists = await self._lidarr_repo.get_artists_from_library()
library_artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
lfm_artists = await self._lfm_repo.get_global_top_artists(limit=limit + 1)
artists = [
@ -366,10 +376,15 @@ class HomeChartsService:
async def _get_popular_albums_lastfm(self, limit: int = 10) -> PopularAlbumsResponse:
ranges = ["this_week", "this_month", "this_year", "all_time"]
library_albums = await self._lidarr_repo.get_library()
library_albums, monitored_mbids_result = await asyncio.gather(
self._lidarr_repo.get_library(include_unmonitored=True),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
lfm_username = self._get_lastfm_username()
if lfm_username:
tasks = {
@ -408,6 +423,7 @@ class HomeChartsService:
image_url=album.image_url or None,
listen_count=album.playcount,
in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False,
monitored=not ((album.mbid or "").lower() in library_mbids) and (album.mbid or "").lower() in monitored_mbids if album.mbid else False,
source="lastfm",
)
for album in lfm_albums
@ -433,7 +449,7 @@ class HomeChartsService:
total_to_fetch = min(limit + offset + 1, 200)
lfm_artists, library_artists = await asyncio.gather(
self._lfm_repo.get_global_top_artists(limit=total_to_fetch),
self._lidarr_repo.get_artists_from_library(),
self._lidarr_repo.get_artists_from_library(include_unmonitored=True),
)
library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")}
artists = [
@ -470,17 +486,20 @@ class HomeChartsService:
)
total_to_fetch = min(limit + offset + 1, 200)
lfm_albums, library_albums = await asyncio.gather(
lfm_albums, library_albums, monitored_mbids_result = await asyncio.gather(
self._lfm_repo.get_user_top_albums(
lfm_username,
period=self._lastfm_period_for_range(range_key),
limit=total_to_fetch,
),
self._lidarr_repo.get_library(),
self._lidarr_repo.get_library(include_unmonitored=True),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
albums = [
HomeAlbum(
mbid=album.mbid,
@ -490,6 +509,7 @@ class HomeChartsService:
image_url=album.image_url or None,
listen_count=album.playcount,
in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False,
monitored=not ((album.mbid or "").lower() in library_mbids) and (album.mbid or "").lower() in monitored_mbids if album.mbid else False,
source="lastfm",
)
for album in lfm_albums
@ -521,10 +541,15 @@ class HomeChartsService:
this_week=empty, this_month=empty, this_year=empty, all_time=empty
)
library_albums = await self._lidarr_repo.get_library()
library_albums, monitored_mbids_result = await asyncio.gather(
self._lidarr_repo.get_library(include_unmonitored=True),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
ranges = ["this_week", "this_month", "this_year", "all_time"]
tasks = {
r: self._lb_repo.get_user_top_release_groups(
@ -536,7 +561,7 @@ class HomeChartsService:
response_data: dict[str, PopularTimeRange] = {}
for r in ranges:
rgs = results.get(r) or []
albums = [self._transformers.lb_release_to_home(rg, library_mbids) for rg in rgs]
albums = [self._transformers.lb_release_to_home(rg, library_mbids, monitored_mbids) for rg in rgs]
response_data[r] = PopularTimeRange(
range_key=r,
label=HomeDataTransformers.get_range_label(r),
@ -578,16 +603,19 @@ class HomeChartsService:
has_more=False,
)
library_albums, rgs = await asyncio.gather(
self._lidarr_repo.get_library(),
library_albums, rgs, monitored_mbids_result = await asyncio.gather(
self._lidarr_repo.get_library(include_unmonitored=True),
self._lb_repo.get_user_top_release_groups(
username=lb_username, range_=range_key, count=limit + 1, offset=offset
),
self._lidarr_repo.get_monitored_no_files_mbids(),
return_exceptions=True,
)
library_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
albums = [self._transformers.lb_release_to_home(rg, library_mbids) for rg in rgs]
monitored_mbids = monitored_mbids_result if not isinstance(monitored_mbids_result, BaseException) else set()
albums = [self._transformers.lb_release_to_home(rg, library_mbids, monitored_mbids) for rg in rgs]
has_more = len(albums) > limit
items = albums[:limit]
return PopularAlbumsRangeResponse(

View file

@ -162,9 +162,10 @@ class HomeService:
)
if lidarr_configured:
tasks["library_albums"] = self._lidarr_repo.get_library()
tasks["library_artists"] = self._lidarr_repo.get_artists_from_library()
tasks["library_albums"] = self._lidarr_repo.get_library(include_unmonitored=True)
tasks["library_artists"] = self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
tasks["recently_imported"] = self._lidarr_repo.get_recently_imported(limit=15)
tasks["monitored_mbids"] = self._lidarr_repo.get_monitored_no_files_mbids()
if resolved_source == "listenbrainz" and lb_enabled and username:
lb_settings = self._preferences.get_listenbrainz_connection()
@ -195,6 +196,7 @@ class HomeService:
library_album_mbids = {
(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id
}
monitored_mbids: set[str] = results.get("monitored_mbids") or set()
response = HomeResponse(integration_status=integration_status)
@ -207,10 +209,10 @@ class HomeService:
results, library_artist_mbids
)
response.popular_albums = self._builders.build_popular_albums_section(
results, library_album_mbids
results, library_album_mbids, monitored_mbids
)
response.your_top_albums = self._builders.build_lb_user_top_albums_section(
results, library_album_mbids
results, library_album_mbids, monitored_mbids
)
response.recently_played = self._builders.build_listenbrainz_recent_section(results)
response.favorite_artists = self._builders.build_listenbrainz_favorites_section(results)
@ -220,7 +222,7 @@ class HomeService:
results, library_artist_mbids
)
response.your_top_albums = self._builders.build_lastfm_top_albums_section(
results, library_album_mbids
results, library_album_mbids, monitored_mbids
)
response.recently_played = self._builders.build_lastfm_recent_section(results)
response.favorite_artists = self._builders.build_lastfm_favorites_section(results)

View file

@ -60,24 +60,24 @@ class HomeSectionBuilders:
)
def build_popular_albums_section(
self, results: dict[str, Any], library_mbids: set[str]
self, results: dict[str, Any], library_mbids: set[str], monitored_mbids: set[str] | None = None
) -> HomeSection:
albums = results.get("lb_trending_albums") or []
return HomeSection(
title="Popular Right Now",
type="albums",
items=[self._transformers.lb_release_to_home(a, library_mbids) for a in albums[:15]],
items=[self._transformers.lb_release_to_home(a, library_mbids, monitored_mbids) for a in albums[:15]],
source="listenbrainz" if albums else None,
)
def build_lb_user_top_albums_section(
self, results: dict[str, Any], library_mbids: set[str]
self, results: dict[str, Any], library_mbids: set[str], monitored_mbids: set[str] | None = None
) -> HomeSection | None:
release_groups = results.get("lb_user_top_rgs") or []
if not release_groups:
return None
items = [
self._transformers.lb_release_to_home(rg, library_mbids)
self._transformers.lb_release_to_home(rg, library_mbids, monitored_mbids)
for rg in release_groups[:15]
]
return HomeSection(
@ -95,7 +95,8 @@ class HomeSectionBuilders:
return HomeSection(title="Browse by Genre", type="genres", items=genres, source=source)
def build_fresh_releases_section(
self, results: dict[str, Any], library_mbids: set[str]
self, results: dict[str, Any], library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeSection | None:
releases = results.get("lb_fresh")
if not releases:
@ -103,7 +104,7 @@ class HomeSectionBuilders:
return HomeSection(
title="New From Artists You Follow",
type="albums",
items=[self._transformers.lb_release_to_home(r, library_mbids) for r in releases[:15]],
items=[self._transformers.lb_release_to_home(r, library_mbids, monitored_mbids) for r in releases[:15]],
source="listenbrainz",
)
@ -170,12 +171,12 @@ class HomeSectionBuilders:
)
def build_lastfm_top_albums_section(
self, results: dict[str, Any], library_mbids: set[str]
self, results: dict[str, Any], library_mbids: set[str], monitored_mbids: set[str] | None = None
) -> HomeSection:
albums = results.get("lfm_top_albums") or []
items = [
a for a in (
self._transformers.lastfm_album_to_home(album, library_mbids)
self._transformers.lastfm_album_to_home(album, library_mbids, monitored_mbids)
for album in albums[:15]
)
if a is not None

View file

@ -70,9 +70,15 @@ class HomeDataTransformers:
def lb_release_to_home(
self,
release: ListenBrainzReleaseGroup,
library_mbids: set[str]
library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeAlbum:
artist_mbid = release.artist_mbids[0] if release.artist_mbids else None
mbid_lower = (release.release_group_mbid or "").lower()
in_library = mbid_lower in library_mbids
monitored = (
not in_library and bool(monitored_mbids) and mbid_lower in monitored_mbids
)
return HomeAlbum(
mbid=release.release_group_mbid,
name=release.release_group_name,
@ -81,7 +87,8 @@ class HomeDataTransformers:
image_url=None,
release_date=None,
listen_count=release.listen_count,
in_library=(release.release_group_mbid or "").lower() in library_mbids,
in_library=in_library,
monitored=monitored,
)
def jf_item_to_artist(
@ -130,7 +137,13 @@ class HomeDataTransformers:
self,
album: LastFmAlbum,
library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeAlbum | None:
mbid_lower = album.mbid.lower() if album.mbid else ""
in_library = mbid_lower in library_mbids if mbid_lower else False
monitored = (
not in_library and bool(monitored_mbids) and mbid_lower in monitored_mbids
) if mbid_lower else False
return HomeAlbum(
mbid=None,
name=album.name,
@ -138,7 +151,8 @@ class HomeDataTransformers:
artist_mbid=None,
image_url=album.image_url or None,
listen_count=album.playcount,
in_library=album.mbid.lower() in library_mbids if album.mbid else False,
in_library=in_library,
monitored=monitored,
source="lastfm",
)
@ -159,14 +173,21 @@ class HomeDataTransformers:
self,
track: LastFmRecentTrack,
library_mbids: set[str],
monitored_mbids: set[str] | None = None,
) -> HomeAlbum | None:
mbid_lower = (track.album_mbid or "").lower()
in_library = mbid_lower in library_mbids if track.album_mbid else False
monitored = (
not in_library and bool(monitored_mbids) and bool(mbid_lower) and mbid_lower in monitored_mbids
)
return HomeAlbum(
mbid=track.album_mbid,
name=track.album_name or track.track_name,
artist_name=track.artist_name,
artist_mbid=track.artist_mbid,
image_url=track.image_url or None,
in_library=track.album_mbid.lower() in library_mbids if track.album_mbid else False,
in_library=in_library,
monitored=monitored,
source="lastfm",
)

View file

@ -167,6 +167,15 @@ class LibraryService:
except Exception as e: # noqa: BLE001
logger.error(f"Failed to fetch requested mbids: {e}")
raise ExternalServiceError(f"Failed to fetch requested mbids: {e}")
async def get_monitored_mbids(self) -> list[str]:
if not self._lidarr_repo.is_configured():
return []
try:
return list(await self._lidarr_repo.get_monitored_no_files_mbids())
except Exception as e: # noqa: BLE001
logger.error(f"Failed to fetch monitored mbids: {e}")
raise ExternalServiceError(f"Failed to fetch monitored mbids: {e}")
async def get_artists(self, limit: int | None = None) -> list[LibraryArtist]:
try:
@ -348,8 +357,8 @@ class LibraryService:
sync_succeeded = False
try:
albums = await self._lidarr_repo.get_library()
artists = await self._lidarr_repo.get_artists_from_library()
albums = await self._lidarr_repo.get_library(include_unmonitored=True)
artists = await self._lidarr_repo.get_artists_from_library(include_unmonitored=True)
albums_data = [
{

View file

@ -29,7 +29,7 @@ class AlbumPhase:
async def precache_album_data(
self,
release_group_ids: list[str],
monitored_mbids: set[str],
library_mbids: set[str],
status_service: CacheStatusService,
library_album_mbids: dict[str, Any] = None,
offset: int = 0,
@ -48,11 +48,11 @@ class AlbumPhase:
cached_info = await album_service._cache.get(cache_key)
if not cached_info:
await status_service.update_progress(index + 1, f"Fetching metadata for {rgid[:8]}...", processed_albums=offset + index + 1, generation=generation)
await album_service.get_album_info(rgid, monitored_mbids=monitored_mbids)
await album_service.get_album_info(rgid, library_mbids=library_mbids)
metadata_fetched = True
else:
await status_service.update_progress(index + 1, f"Cached: {rgid[:8]}...", processed_albums=offset + index + 1, generation=generation)
if rgid.lower() in monitored_mbids:
if rgid.lower() in library_mbids:
cache_filename = get_cache_filename(f"rg_{rgid}", "500")
file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin"
if not file_path.exists():

View file

@ -210,12 +210,12 @@ class LibraryPrecacheService:
if status_service.is_cancelled():
return
monitored_mbids: set[str] = set()
library_album_mbids_set: set[str] = set()
for a in albums:
mbid = getattr(a, 'musicbrainz_id', None) if hasattr(a, 'musicbrainz_id') else a.get('mbid') if isinstance(a, dict) else None
if not is_unknown_mbid(mbid):
monitored_mbids.add(mbid.lower())
deduped_release_groups = list(monitored_mbids)
library_album_mbids_set.add(mbid.lower())
deduped_release_groups = list(library_album_mbids_set)
if status_service.is_cancelled():
return
items_needing_metadata = []
@ -234,7 +234,7 @@ class LibraryPrecacheService:
for rgid in deduped_release_groups:
if rgid in processed_albums:
continue
if rgid.lower() in monitored_mbids:
if rgid.lower() in library_album_mbids_set:
cache_filename = get_cache_filename(f"rg_{rgid}", "500")
file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin"
cover_paths.append((rgid, file_path))
@ -245,7 +245,7 @@ class LibraryPrecacheService:
already_cached = len(deduped_release_groups) - len(items_to_process) - len(processed_albums)
if items_to_process:
await status_service.update_phase('albums', len(items_to_process), generation=generation)
await self._album_phase.precache_album_data(items_to_process, monitored_mbids, status_service, library_album_mbids, len(processed_albums), generation=generation)
await self._album_phase.precache_album_data(items_to_process, library_album_mbids_set, status_service, library_album_mbids, len(processed_albums), generation=generation)
else:
await status_service.skip_phase('albums', generation=generation)

View file

@ -164,7 +164,7 @@ class SearchService:
limits["albums"] = limit_albums
try:
grouped, library_mbids_raw, queue_items_raw = await self._safe_gather(
grouped, library_mbids_raw, queue_items_raw, monitored_mbids_raw = await self._safe_gather(
self._mb_repo.search_grouped(
query,
limits=limits,
@ -173,10 +173,11 @@ class SearchService:
),
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(),
self._lidarr_repo.get_monitored_no_files_mbids(),
)
except Exception as e: # noqa: BLE001
logger.error(f"Search gather failed unexpectedly: {e}")
grouped, library_mbids_raw, queue_items_raw = None, None, None
grouped, library_mbids_raw, queue_items_raw, monitored_mbids_raw = None, None, None, None
if grouped is None:
logger.warning("MusicBrainz search returned no results or failed")
@ -188,10 +189,13 @@ class SearchService:
else:
queued_mbids = set()
monitored_mbids = monitored_mbids_raw or set()
for item in grouped.get("albums", []):
mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library
item.monitored = mbid_lower in monitored_mbids and not item.in_library and not item.requested
all_results = grouped.get("artists", []) + grouped.get("albums", [])
await self._apply_audiodb_search_overlay(all_results)
@ -246,9 +250,10 @@ class SearchService:
return [], None
if bucket == "albums":
library_mbids_raw, queue_items_raw = await self._safe_gather(
library_mbids_raw, queue_items_raw, monitored_mbids_raw = await self._safe_gather(
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(),
self._lidarr_repo.get_monitored_no_files_mbids(),
)
library_mbids = library_mbids_raw or set()
if queue_items_raw:
@ -256,10 +261,13 @@ class SearchService:
else:
queued_mbids = set()
monitored_mbids = monitored_mbids_raw or set()
for item in results:
mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library
item.monitored = mbid_lower in monitored_mbids and not item.in_library and not item.requested
await self._apply_audiodb_search_overlay(results)
@ -288,9 +296,10 @@ class SearchService:
grouped = grouped or {"artists": [], "albums": []}
library_mbids_raw, queue_items_raw = await self._safe_gather(
library_mbids_raw, queue_items_raw, monitored_mbids_raw = await self._safe_gather(
self._lidarr_repo.get_library_mbids(include_release_ids=True),
self._lidarr_repo.get_queue(),
self._lidarr_repo.get_monitored_no_files_mbids(),
)
library_mbids = library_mbids_raw or set()
if queue_items_raw:
@ -298,10 +307,13 @@ class SearchService:
else:
queued_mbids = set()
monitored_mbids = monitored_mbids_raw or set()
for item in grouped.get("albums", []):
mbid_lower = (item.musicbrainz_id or "").lower()
item.in_library = mbid_lower in library_mbids
item.requested = mbid_lower in queued_mbids and not item.in_library
item.monitored = mbid_lower in monitored_mbids and not item.in_library and not item.requested
suggestions: list[SuggestResult] = []
for item in grouped.get("artists", []) + grouped.get("albums", []):
@ -313,6 +325,7 @@ class SearchService:
musicbrainz_id=item.musicbrainz_id,
in_library=item.in_library,
requested=item.requested,
monitored=item.monitored,
disambiguation=item.disambiguation,
score=item.score,
))

View file

@ -8,6 +8,10 @@ from infrastructure.cache.disk_cache import DiskMetadataCache
from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages
def _cache_hash(identifier: str) -> str:
return hashlib.sha1(f"{DiskMetadataCache._CACHE_VERSION}:{identifier}".encode()).hexdigest()
@pytest.mark.asyncio
async def test_set_album_serializes_msgspec_struct_as_mapping(tmp_path):
cache = DiskMetadataCache(base_path=tmp_path)
@ -22,7 +26,7 @@ async def test_set_album_serializes_msgspec_struct_as_mapping(tmp_path):
await cache.set_album(mbid, album_info, is_monitored=True)
cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
cache_hash = _cache_hash(mbid)
cache_file = tmp_path / "persistent" / "albums" / f"{cache_hash}.json"
payload = json.loads(cache_file.read_text())
@ -39,7 +43,7 @@ async def test_get_album_deletes_corrupt_string_payload(tmp_path):
cache = DiskMetadataCache(base_path=tmp_path)
mbid = "8e1e9e51-38dc-4df3-8027-a0ada37d4674"
cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
cache_hash = _cache_hash(mbid)
cache_file = tmp_path / "persistent" / "albums" / f"{cache_hash}.json"
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps("AlbumInfo(title='Corrupt')"))
@ -69,7 +73,7 @@ async def test_audiodb_artist_entity_routing(tmp_path):
assert result["fanart_url"] == "https://example.com/fanart.jpg"
assert result["lookup_source"] == "mbid"
cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
cache_hash = _cache_hash(mbid)
data_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json"
assert data_file.exists()
@ -93,7 +97,7 @@ async def test_audiodb_album_entity_routing(tmp_path):
assert result["album_back_url"] == "https://example.com/album_back.jpg"
assert result["lookup_source"] == "name"
cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
cache_hash = _cache_hash(mbid)
persistent_file = tmp_path / "persistent" / "audiodb_albums" / f"{cache_hash}.json"
assert persistent_file.exists()
@ -160,7 +164,7 @@ async def test_audiodb_monitored_persistent_vs_recent(tmp_path):
await cache._set_entity("audiodb_artist", mbid, images, is_monitored=True, ttl_seconds=None)
cache_hash = hashlib.sha1(mbid.encode()).hexdigest()
cache_hash = _cache_hash(mbid)
persistent_file = tmp_path / "persistent" / "audiodb_artists" / f"{cache_hash}.json"
recent_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json"
assert persistent_file.exists()

View file

@ -26,6 +26,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2023-01-15",
"added": "2023-01-10T12:00:00Z",
"images": [],
"statistics": {"trackFileCount": 5},
"artist": {
"artistName": "Artist A",
"foreignArtistId": "artist-a-mbid",
@ -39,6 +40,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2024-06-01",
"added": "2024-06-01T08:00:00Z",
"images": [],
"statistics": {"trackFileCount": 8},
"artist": {
"artistName": "Artist B",
"foreignArtistId": "artist-b-mbid",
@ -52,6 +54,7 @@ def _sample_album_data() -> list[dict]:
"releaseDate": "2020-03-01",
"added": "2020-03-01T00:00:00Z",
"images": [],
"statistics": {"trackFileCount": 3},
"artist": {
"artistName": "Artist C",
"foreignArtistId": "artist-c-mbid",

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")
assert result.in_library is False
assert result.in_library is True
library_db.get_album_by_mbid.assert_not_awaited()
@ -102,7 +102,7 @@ async def test_get_album_tracks_info_preserves_disc_numbers_from_lidarr():
service, lidarr_repo, _ = _make_service()
service._get_cached_album_info = AsyncMock(return_value=None)
lidarr_repo.is_configured.return_value = True
lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True})
lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True, "statistics": {"trackFileCount": 1}})
lidarr_repo.get_album_tracks = AsyncMock(
return_value=[
{
@ -135,7 +135,7 @@ async def test_get_album_tracks_info_multi_disc_same_track_numbers():
service, lidarr_repo, _ = _make_service()
service._get_cached_album_info = AsyncMock(return_value=None)
lidarr_repo.is_configured.return_value = True
lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True})
lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True, "statistics": {"trackFileCount": 1}})
lidarr_repo.get_album_tracks = AsyncMock(
return_value=[
{"track_number": 1, "disc_number": 1, "title": "Intro", "duration_ms": 1000},

View file

@ -42,6 +42,7 @@ def _make_service(*, cached_artist: ArtistInfo | None = None) -> tuple[ArtistSer
lidarr_repo.is_configured.return_value = False
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
lidarr_repo.get_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock()

View file

@ -53,6 +53,7 @@ def _make_service(
lidarr_repo.get_artist_details = AsyncMock(return_value=lidarr_artist)
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
lidarr_repo.get_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock()

View file

@ -52,6 +52,7 @@ def _search_service(audiodb: MagicMock | None = None) -> SearchService:
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[])

View file

@ -56,6 +56,7 @@ def _search_service(audiodb=None) -> SearchService:
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[])

View file

@ -59,6 +59,8 @@ def _make_service(
else:
lidarr_repo.get_queue = AsyncMock(return_value=queue_items or [])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
preferences_service = MagicMock()
preferences_service.get_preferences.return_value = _make_preferences()
@ -300,6 +302,7 @@ async def test_suggest_deduplication_single_mb_call():
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
preferences_service = MagicMock()

View file

@ -156,6 +156,7 @@ def _make_search_service(audiodb_service=None) -> SearchService:
lidarr_repo = MagicMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_queue = AsyncMock(return_value=[])
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
coverart_repo = MagicMock()
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(secondary_types=[])

View file

@ -66,7 +66,7 @@ class TestCooldownOnlyOnSuccess:
async def test_retry_after_failed_sync_is_not_cooldown_blocked(self):
call_count = 0
async def fail_then_succeed():
async def fail_then_succeed(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
@ -94,7 +94,7 @@ class TestSyncFutureDedup:
call_count = 0
sync_event = asyncio.Event()
async def slow_get_library():
async def slow_get_library(**kwargs):
nonlocal call_count
call_count += 1
sync_event.set()
@ -118,7 +118,7 @@ class TestSyncFutureDedup:
@pytest.mark.asyncio
async def test_concurrent_sync_failure_propagates_to_waiter(self):
"""When the producer fails, deduped waiters get the real exception."""
async def failing_get_library():
async def failing_get_library(**kwargs):
await asyncio.sleep(0.05)
raise RuntimeError("Lidarr DNS failure")

View file

@ -5,6 +5,8 @@
import { libraryStore } from '$lib/stores/library';
import { integrationStore } from '$lib/stores/integration';
import { requestAlbum } from '$lib/utils/albumRequest';
import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
import { toastStore } from '$lib/stores/toast';
import { formatListenCount } from '$lib/utils/formatting';
import { getListenTitle } from '$lib/utils/enrichment';
import { Download, Music2 } from 'lucide-svelte';
@ -29,6 +31,7 @@
let listenTitle = $derived(getListenTitle(enrichmentSource, 'album'));
let requesting = $state(false);
let monitoredLoading = $state(false);
let inLibrary = $derived(
libraryStore.isInLibrary(album.musicbrainz_id) || album.in_library || false
@ -38,6 +41,11 @@
!album.in_library &&
(album.requested || libraryStore.isRequested(album.musicbrainz_id))
);
let isMonitored = $derived(
!inLibrary &&
!isRequested &&
(album.monitored || libraryStore.isMonitored(album.musicbrainz_id))
);
async function handleRequest(e: Event) {
e.stopPropagation();
@ -56,9 +64,23 @@
}
}
async function handleToggleMonitored() {
monitoredLoading = true;
try {
await toggleAlbumMonitored(album.musicbrainz_id, false);
album.monitored = false;
album = album;
} catch {
toastStore.show({ message: 'Failed to update monitoring status', type: 'error' });
} finally {
monitoredLoading = false;
}
}
function handleDeleted() {
album.in_library = false;
album.requested = false;
album.monitored = false;
album = album;
onremoved?.();
}
@ -161,6 +183,15 @@
artistName={album.artist || 'Unknown'}
ondeleted={handleDeleted}
/>
{:else if isMonitored}
<LibraryBadge
status="monitored"
musicbrainzId={album.musicbrainz_id}
albumTitle={album.title}
artistName={album.artist || 'Unknown'}
ontogglemonitored={handleToggleMonitored}
{monitoredLoading}
/>
{/if}
{/if}
</div>

View file

@ -28,6 +28,7 @@
musicbrainz_id: da.musicbrainz_id,
in_library: da.in_library,
requested: da.requested,
monitored: da.monitored,
cover_url: da.cover_url
};
}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Check } from 'lucide-svelte';
import { Check, Bookmark } from 'lucide-svelte';
import AlbumImage from '$lib/components/AlbumImage.svelte';
import AlbumCardOverlay from '$lib/components/AlbumCardOverlay.svelte';
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">
<Check class="w-3 h-3" />
</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 album.mbid && album.in_library}
<AlbumCardOverlay

View file

@ -10,6 +10,7 @@
ArrowRight,
X,
Check,
Bookmark,
Music2,
Tv,
Sparkles,
@ -184,7 +185,7 @@
{/if}
{#if item.in_library}
<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>
{/if}
</figure>
@ -217,7 +218,11 @@
/>
{#if item.in_library}
<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>
{/if}
{#if item.mbid && item.in_library}

View file

@ -32,6 +32,7 @@
musicbrainz_id: rg.id,
in_library: libraryStore.isInLibrary(rg.id) || rg.in_library,
requested: rg.requested,
monitored: rg.monitored,
cover_url: null,
type_info: rg.type
};

View file

@ -1,18 +1,20 @@
<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 { STATUS_COLORS } from '$lib/constants';
import DeleteAlbumModal from './DeleteAlbumModal.svelte';
import ArtistRemovedModal from './ArtistRemovedModal.svelte';
interface Props {
status: 'library' | 'requested';
status: 'library' | 'requested' | 'monitored';
musicbrainzId: string;
albumTitle: string;
artistName: string;
size?: 'sm' | 'md' | 'lg';
positioning?: string;
ondeleted?: (result: { artist_removed: boolean; artist_name?: string | null }) => void;
ontogglemonitored?: () => void;
monitoredLoading?: boolean;
}
let {
@ -22,7 +24,9 @@
artistName,
size = 'md',
positioning = '',
ondeleted
ondeleted,
ontogglemonitored,
monitoredLoading = false
}: Props = $props();
let showDeleteModal = $state(false);
@ -31,11 +35,11 @@
const sizeClasses = $derived(
{
sm: { button: 'p-0.5', icon: 'w-2.5 h-2.5', strokeWidth: status === 'library' ? '3' : '2' },
md: { button: 'p-1.5', icon: 'h-4 w-4', 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-5 w-5', strokeWidth: status === 'library' ? '3' : '2' },
lg: {
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'
}
}[size]
@ -44,11 +48,21 @@
const lgButtonClass = $derived(
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) {
e.stopPropagation();
e.preventDefault();
if (status === 'monitored') {
ontogglemonitored?.();
return;
}
showDeleteModal = true;
}
@ -66,31 +80,62 @@
class="{positioning} rounded-full shadow-sm transition-colors duration-200 group/badge {sizeClasses.button} {lgButtonClass}"
style="background-color: {bgColor};"
onclick={handleClick}
onmouseenter={(e) => {
e.currentTarget.style.backgroundColor = '#ef4444';
}}
onmouseenter={status === 'monitored'
? (e) => {
e.currentTarget.style.filter = 'brightness(1.3)';
}
: (e) => {
e.currentTarget.style.backgroundColor = '#ef4444';
}}
onmouseleave={(e) => {
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
class="{sizeClasses.icon} group-hover/badge:hidden"
color={colors.secondary}
strokeWidth={Number(sizeClasses.strokeWidth)}
/>
{:else}
{:else if status === 'requested'}
<Clock
class="{sizeClasses.icon} group-hover/badge:hidden"
color={colors.secondary}
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}
<Trash2 class="{sizeClasses.icon} hidden group-hover/badge:block" color="white" strokeWidth={2} />
</button>
{#if showDeleteModal}
{#if status !== 'monitored' && showDeleteModal}
<DeleteAlbumModal
{albumTitle}
{artistName}

View file

@ -3,6 +3,8 @@
import { ChevronDown, Download } from 'lucide-svelte';
import { colors } from '$lib/colors';
import { libraryStore } from '$lib/stores/library';
import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
import { toastStore } from '$lib/stores/toast';
import AlbumImage from './AlbumImage.svelte';
import LibraryBadge from './LibraryBadge.svelte';
@ -12,6 +14,7 @@
year?: number | null;
in_library?: boolean;
requested?: boolean;
monitored?: boolean;
}
interface RemoveResult {
@ -46,11 +49,31 @@
function handleDeleted(rg: Release, result: RemoveResult) {
rg.in_library = false;
rg.requested = false;
rg.monitored = false;
releases = releases;
onRemoved?.(result);
}
</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="bg-base-300 rounded-t-box">
<button
@ -105,30 +128,31 @@
size="lg"
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}
<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="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>
{@render requestButton(rg, `Request ${title.toLowerCase().slice(0, -1)}`)}
{/if}
</div>
</div>

View file

@ -271,6 +271,9 @@
{#if result.requested}
<span class="badge badge-sm badge-warning">Requested</span>
{/if}
{#if result.monitored && !result.in_library && !result.requested}
<span class="badge badge-sm badge-neutral">Monitored</span>
{/if}
</div>
</li>
{/each}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Check, Search } from 'lucide-svelte';
import { Check, Bookmark, Search } from 'lucide-svelte';
import type { HomeAlbum, HomeArtist } from '$lib/types';
import { formatListenCount } from '$lib/utils/formatting';
import AlbumImage from './AlbumImage.svelte';
@ -82,6 +82,11 @@
<Check class="h-3 w-3" />
In Library
</div>
{:else if item.monitored && !('requested' in item && item.requested)}
<div class="badge badge-neutral">
<Bookmark class="h-3 w-3" />
Monitored
</div>
{/if}
</div>
{#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">
<Check class="h-3 w-3" />
</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 item.mbid && item.in_library}
<AlbumCardOverlay

View file

@ -6,6 +6,8 @@
import { colors } from '$lib/colors';
import { libraryStore } from '$lib/stores/library';
import { requestAlbum } from '$lib/utils/albumRequest';
import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
import { toastStore } from '$lib/stores/toast';
import AlbumImage from './AlbumImage.svelte';
import LibraryBadge from './LibraryBadge.svelte';
import LastFmPlaceholder from './LastFmPlaceholder.svelte';
@ -24,12 +26,14 @@
let libraryMbids = new SvelteSet<string>();
let requestedMbids = new SvelteSet<string>();
let monitoredMbids = new SvelteSet<string>();
let storeInitialized = $state(false);
onMount(() => {
const unsubscribe = libraryStore.subscribe((state) => {
libraryMbids = new SvelteSet(state.mbidSet);
requestedMbids = new SvelteSet(state.requestedSet);
monitoredMbids = new SvelteSet(state.monitoredSet);
storeInitialized = state.initialized;
});
return unsubscribe;
@ -48,6 +52,14 @@
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 {
return album.release_group_mbid ? requestingIds.has(album.release_group_mbid) : false;
}
@ -129,6 +141,26 @@
size="sm"
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}
</div>
@ -143,7 +175,7 @@
</p>
</div>
{#if !isInLibrary(album) && !isRequested(album)}
{#if !isInLibrary(album) && !isRequested(album) && !isMonitored(album)}
<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"

View file

@ -131,7 +131,8 @@ export const PLACEHOLDER_COLORS = {
} as const;
export const STATUS_COLORS = {
REQUESTED: '#F59E0B'
REQUESTED: '#F59E0B',
MONITORED: '#6B7280'
} 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;

View file

@ -6,6 +6,7 @@ import { api } from '$lib/api/client';
export interface LibraryState {
mbidSet: Set<string>;
requestedSet: Set<string>;
monitoredSet: Set<string>;
loading: boolean;
lastUpdated: number | null;
initialized: boolean;
@ -14,6 +15,7 @@ export interface LibraryState {
const initialState: LibraryState = {
mbidSet: new Set(),
requestedSet: new Set(),
monitoredSet: new Set(),
loading: false,
lastUpdated: null,
initialized: false
@ -22,6 +24,7 @@ const initialState: LibraryState = {
type LibraryCacheData = {
mbids: string[];
requested: string[];
monitored: string[];
};
function createLibraryStore() {
@ -33,18 +36,24 @@ function createLibraryStore() {
function normalizeCachedData(data: LibraryCacheData | string[]): LibraryCacheData {
if (Array.isArray(data)) {
return { mbids: data, requested: [] };
return { mbids: data, requested: [], monitored: [] };
}
return {
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({
mbids: [...mbidSet],
requested: [...requestedSet]
requested: [...requestedSet],
monitored: [...monitoredSet]
});
}
@ -57,8 +66,9 @@ function createLibraryStore() {
const normalized = normalizeCachedData(cached.data);
const mbids = normalized.mbids.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);
return;
}
@ -67,6 +77,7 @@ function createLibraryStore() {
...s,
mbidSet: new Set(mbids),
requestedSet: new Set(requested),
monitoredSet: new Set(monitored),
lastUpdated: cached.timestamp,
initialized: true
}));
@ -86,22 +97,26 @@ function createLibraryStore() {
}
try {
const data = await api.global.get<{ mbids?: string[]; requested_mbids?: string[] }>(
'/api/v1/library/mbids'
);
const data = await api.global.get<{
mbids?: string[];
requested_mbids?: string[];
monitored_mbids?: string[];
}>('/api/v1/library/mbids');
const mbids: string[] = (data.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) => ({
...s,
mbidSet: new Set(mbids),
requestedSet: new Set(requested),
monitoredSet: new Set(monitored),
loading: false,
lastUpdated: Date.now(),
initialized: true
}));
cache.set({ mbids, requested });
cache.set({ mbids, requested, monitored });
} catch {
if (!background) {
update((s) => ({ ...s, loading: false, initialized: true }));
@ -121,8 +136,10 @@ function createLibraryStore() {
newSet.add(mbid.toLowerCase());
const newRequested = new Set(s.requestedSet);
newRequested.delete(mbid.toLowerCase());
persistState(newSet, newRequested);
return { ...s, mbidSet: newSet, requestedSet: newRequested };
const newMonitored = new Set(s.monitoredSet);
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());
const newRequested = new Set(s.requestedSet);
newRequested.delete(mbid.toLowerCase());
persistState(newSet, newRequested);
return { ...s, mbidSet: newSet, requestedSet: newRequested };
const newMonitored = new Set(s.monitoredSet);
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())) {
return s;
}
const newSet = new Set(s.requestedSet);
newSet.add(mbid.toLowerCase());
persistState(s.mbidSet, newSet);
return { ...s, requestedSet: newSet };
const lower = mbid.toLowerCase();
const newRequested = new Set(s.requestedSet);
newRequested.add(lower);
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 {
if (!mbid) return false;
const lower = mbid.toLowerCase();
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() {
@ -179,6 +225,8 @@ function createLibraryStore() {
removeMbid,
isRequested,
addRequested,
isMonitored,
setMonitored,
updateCacheTTL: cache.updateTTL
};
}

View file

@ -20,6 +20,7 @@ export type Album = {
musicbrainz_id: string;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
cover_url?: string | null;
album_thumb_url?: string | null;
album_back_url?: string | null;
@ -63,6 +64,7 @@ export type SuggestResult = {
musicbrainz_id: string;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
disambiguation?: string | null;
score: number;
};
@ -111,6 +113,7 @@ export type ReleaseGroup = {
first_release_date?: string;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
};
export type ExternalLink = {
@ -207,6 +210,7 @@ export type AlbumInfo = {
total_length?: number | null;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
cover_url?: string | null;
album_thumb_url?: string | null;
album_back_url?: string | null;
@ -229,6 +233,7 @@ export type AlbumBasicInfo = {
disambiguation?: string | null;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
cover_url?: string | null;
album_thumb_url?: string | null;
};
@ -274,6 +279,7 @@ export type HomeArtist = {
image_url: string | null;
listen_count: number | null;
in_library: boolean;
monitored?: boolean;
};
export type HomeAlbum = {
@ -286,6 +292,7 @@ export type HomeAlbum = {
listen_count: number | null;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
};
export type HomeTrack = {
@ -506,6 +513,7 @@ export type SimilarArtist = {
name: string;
listen_count: number;
in_library: boolean;
monitored?: boolean;
image_url?: string | null;
};
@ -552,6 +560,7 @@ export type TopAlbum = {
listen_count: number;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
cover_url?: string | null;
};
@ -569,6 +578,7 @@ export type DiscoveryAlbum = {
year?: number | null;
in_library: boolean;
requested?: boolean;
monitored?: boolean;
cover_url?: string | null;
};
@ -587,6 +597,7 @@ export type DiscoverQueueItemLight = {
recommendation_reason: string;
is_wildcard: boolean;
in_library: boolean;
monitored?: boolean;
};
export type DiscoverQueueEnrichment = {
@ -747,6 +758,7 @@ export type RequestHistoryItem = {
completed_at?: string | null;
status: string;
in_library: boolean;
monitored?: boolean;
};
export type ActiveRequestsResponse = {

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);
}

View file

@ -59,15 +59,19 @@
loadingTracks={state.loadingTracks}
inLibrary={state.inLibrary}
isRequested={state.isRequested}
albumMonitored={state.albumMonitored}
requesting={state.requesting}
refreshing={state.refreshing}
pollingForSources={state.pollingForSources}
lidarrConfigured={$integrationStore.lidarr}
monitorToggleLoading={state.monitorToggleLoading}
artistMonitored={state.artistMonitored}
artistInLidarr={state.artistInLidarr}
onrequest={state.handleRequest}
ondelete={state.handleDeleteClick}
onrefresh={state.refreshAll}
onartistclick={state.goToArtist}
ontogglemonitored={state.handleToggleMonitored}
/>
{#if state.loadingTracks}

View file

@ -5,7 +5,7 @@
import AlbumImage from '$lib/components/AlbumImage.svelte';
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
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 {
album: AlbumBasicInfo;
@ -13,16 +13,20 @@
loadingTracks: boolean;
inLibrary: boolean;
isRequested: boolean;
albumMonitored: boolean;
requesting: boolean;
refreshing: boolean;
pollingForSources: boolean;
lidarrConfigured: boolean;
monitorToggleLoading?: boolean;
artistMonitored?: boolean;
artistInLidarr?: boolean;
onrequest: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void;
ondelete: () => void;
onrefresh: () => void;
onartistclick: () => void;
ontogglemonitored: (monitored: boolean) => void;
}
let {
@ -31,15 +35,19 @@
loadingTracks,
inLibrary,
isRequested,
albumMonitored,
requesting,
refreshing,
pollingForSources,
lidarrConfigured,
monitorToggleLoading = false,
artistMonitored = false,
artistInLidarr = false,
onrequest,
ondelete,
onrefresh,
onartistclick
onartistclick,
ontogglemonitored
}: Props = $props();
let monitorArtist = $state(false);
@ -59,6 +67,8 @@
? getApiUrl(`/api/v1/covers/release-group/${album.musicbrainz_id}?size=250`)
: null)
);
const showMonitorToggle = $derived(lidarrConfigured && artistInLidarr);
</script>
<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">
{#if (inLibrary || isRequested) && lidarrConfigured}
{#if (inLibrary || isRequested || albumMonitored) && lidarrConfigured}
<button
class="absolute top-3 right-3 btn btn-sm btn-ghost btn-circle z-20"
onclick={onrefresh}
@ -223,6 +233,23 @@
{/if}
</div>
{/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>

View file

@ -3,6 +3,7 @@ import { artistHref } from '$lib/utils/entityRoutes';
import type { AlbumBasicInfo, YouTubeTrackLink, YouTubeLink, YouTubeQuotaStatus } from '$lib/types';
import { compareDiscTrack, getDiscTrackKey } from '$lib/player/queueHelpers';
import { requestAlbum } from '$lib/utils/albumRequest';
import { toggleAlbumMonitored } from '$lib/utils/monitorAlbum';
export interface EventHandlerDeps {
getAlbum: () => AlbumBasicInfo | null;
@ -18,6 +19,7 @@ export interface EventHandlerDeps {
setShowDeleteModal: (v: boolean) => void;
setShowArtistRemovedModal: (v: boolean) => void;
setRemovedArtistName: (v: string) => void;
setMonitorToggleLoading: (v: boolean) => void;
setToast: (msg: string, type: 'success' | 'error' | 'info' | 'warning') => void;
setShowToast: (v: boolean) => void;
onRequestSuccess?: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void;
@ -85,6 +87,7 @@ export function createEventHandlers(deps: EventHandlerDeps) {
if (album) {
album.in_library = false;
album.requested = false;
album.monitored = false;
deps.setAlbum(album);
deps.albumBasicCacheSet(album, deps.getAlbumId());
}
@ -96,6 +99,26 @@ export function createEventHandlers(deps: EventHandlerDeps) {
}
}
async function handleToggleMonitored(monitored: boolean): Promise<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 {
const album = deps.getAlbum();
// eslint-disable-next-line svelte/no-navigation-without-resolve -- artistHref uses resolve() internally
@ -110,6 +133,7 @@ export function createEventHandlers(deps: EventHandlerDeps) {
handleRequest,
handleDeleteClick,
handleDeleted,
handleToggleMonitored,
goToArtist
};
}

View file

@ -108,6 +108,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
let playlistModalRef = $state<{ open: (tracks: QueueItem[]) => void } | null>(null);
let abortController: AbortController | null = null;
let refreshing = $state(false);
let monitorToggleLoading = $state(false);
let pollingForSources = $state(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let artistInLidarr = $state(false);
@ -130,6 +131,17 @@ export function createAlbumPageState(albumIdGetter: () => string) {
const isRequested = $derived(
!!(album && !inLibrary && (album.requested || libraryStore.isRequested(album.musicbrainz_id)))
);
const isMonitored = $derived(
!!(
album &&
!inLibrary &&
!isRequested &&
(album.monitored || libraryStore.isMonitored(album.musicbrainz_id))
)
);
const albumMonitored = $derived(
!!(album && (album.monitored || libraryStore.isMonitored(album.musicbrainz_id)))
);
function resetState() {
if (abortController) {
@ -562,6 +574,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
setShowDeleteModal: (v) => (showDeleteModal = v),
setShowArtistRemovedModal: (v) => (showArtistRemovedModal = v),
setRemovedArtistName: (v) => (removedArtistName = v),
setMonitorToggleLoading: (v) => (monitorToggleLoading = v),
setToast: (msg, type) => {
toastMessage = msg;
toastType = type;
@ -800,6 +813,12 @@ export function createAlbumPageState(albumIdGetter: () => string) {
get isRequested() {
return isRequested;
},
get isMonitored() {
return isMonitored;
},
get albumMonitored() {
return albumMonitored;
},
get artistInLidarr() {
return artistInLidarr;
},
@ -809,6 +828,9 @@ export function createAlbumPageState(albumIdGetter: () => string) {
get refreshing() {
return refreshing;
},
get monitorToggleLoading() {
return monitorToggleLoading;
},
get pollingForSources() {
return pollingForSources;
},