Mus 19 library sync completing with issues (#29)

* fix: Sync issues - AudioDB warmig +  automatic sync skips

* progress ui/ux + discovery and album fixes

* artist fixes

* several request level fixes and improvements

* handle request fails + artist refresh + resilience fixes

* fix format

* fix stop sync fail + last.fn mbid issues + failures/validation reworks
This commit is contained in:
Harvey
2026-04-08 00:29:36 +01:00
committed by GitHub
parent 343bafd7f4
commit df779c9e6d
45 changed files with 2043 additions and 231 deletions
+20 -2
View File
@@ -12,7 +12,7 @@ BACKEND_VIRTUALENV_ZIPAPP := $(BACKEND_DIR)/.virtualenv.pyz
PYTHON ?= python3
NPM ?= pnpm
.PHONY: help backend-venv backend-lint backend-test backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 backend-test-exception-handling backend-test-playlist backend-test-multidisc backend-test-performance backend-test-security backend-test-config-validation backend-test-home backend-test-home-genre backend-test-infra-hardening backend-test-library-pagination backend-test-search-top-result test-audiodb-all frontend-install frontend-build frontend-check frontend-lint frontend-test frontend-test-queuehelpers frontend-test-album-page frontend-test-playlist-detail frontend-test-audiodb-images frontend-browser-install project-map rebuild test check lint ci
.PHONY: help backend-venv backend-lint backend-test backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 backend-test-exception-handling backend-test-playlist backend-test-multidisc backend-test-performance backend-test-security backend-test-config-validation backend-test-home backend-test-home-genre backend-test-infra-hardening backend-test-library-pagination backend-test-search-top-result test-audiodb-all backend-test-artist-page backend-test-monitoring-cache frontend-install frontend-build frontend-format-check frontend-check frontend-lint frontend-test frontend-test-queuehelpers frontend-test-album-page frontend-test-playlist-detail frontend-test-audiodb-images frontend-browser-install project-map rebuild test check lint ci
help: ## Show available targets
@grep -E '^[a-zA-Z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf "%-26s %s\n", $$1, $$2}'
@@ -82,6 +82,9 @@ backend-test-infra-hardening: $(BACKEND_VENV_STAMP) ## Run infrastructure harden
backend-test-discovery-precache: $(BACKEND_VENV_STAMP) ## Run artist discovery precache tests
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_discovery_precache_progress.py tests/services/test_discovery_precache_lock.py tests/infrastructure/test_retry_non_breaking.py -v
backend-test-dedup-cancellation: $(BACKEND_VENV_STAMP) ## Run deduplicator cancellation tests
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_dedup_cancellation.py tests/infrastructure/test_disconnect.py -v
backend-test-library-pagination: $(BACKEND_VENV_STAMP) ## Run library pagination tests
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_library_pagination.py -v
@@ -130,12 +133,21 @@ backend-test-mus15-status-race: $(BACKEND_VENV_STAMP) ## Run MUS-15 status race
backend-test-artist-monitoring: $(BACKEND_VENV_STAMP) ## Run MUS-15B artist monitoring tests
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_artist_monitoring.py -v
backend-test-monitoring-cache: $(BACKEND_VENV_STAMP) ## Run artist monitoring cache/flag refresh tests
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_refresh_library_flags.py tests/test_queue_disk_invalidation.py tests/services/test_artist_utils_tags.py -v
backend-test-album-refresh: $(BACKEND_VENV_STAMP) ## Run album refresh endpoint tests
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_album_refresh.py tests/services/test_navidrome_cache_invalidation.py -v
backend-test-artist-page: $(BACKEND_VENV_STAMP) ## Run artist page latency tests (basic route, releases, Last.fm fast path)
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_artist_basic_route.py tests/routes/test_artist_releases_route.py tests/services/test_artist_basic_info.py tests/services/test_top_albums_lastfm_fast.py -v
test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target
test-sync-all: backend-test-sync-watchdog backend-test-sync-resume backend-test-audiodb-parallel ## Run all sync robustness tests
backend-test-sync-generation: $(BACKEND_VENV_STAMP) ## Run MUS-19 sync generation counter tests
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_sync_generation.py -v
test-sync-all: backend-test-sync-watchdog backend-test-sync-resume backend-test-audiodb-parallel backend-test-sync-generation ## Run all sync robustness tests
frontend-install: ## Install frontend npm dependencies
cd "$(FRONTEND_DIR)" && $(NPM) install
@@ -143,6 +155,9 @@ frontend-install: ## Install frontend npm dependencies
frontend-build: ## Run frontend production build
cd "$(FRONTEND_DIR)" && $(NPM) run build
frontend-format-check: ## Run frontend formatting checks
cd "$(FRONTEND_DIR)" && $(NPM) run format:check
frontend-check: ## Run frontend type checks
cd "$(FRONTEND_DIR)" && $(NPM) run check
@@ -155,6 +170,9 @@ frontend-test: ## Run the frontend vitest suite
frontend-test-queuehelpers: ## Run queue helper regressions
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/queueHelpers.spec.ts
frontend-test-monitored-artists: ## Run pending monitored artist store tests
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/stores/monitoredArtists.spec.ts
frontend-test-album-page: ## Run the album page browser test
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/routes/album/[id]/page.svelte.spec.ts
+1 -1
View File
@@ -36,7 +36,7 @@ async def get_artist(
)
try:
result = await artist_service.get_artist_info(artist_id)
result = await artist_service.get_artist_info_basic(artist_id)
ctx = try_get_degradation_context()
if ctx and ctx.has_degradation():
result = msgspec.structs.replace(result, service_status=ctx.degraded_summary())
+11
View File
@@ -36,6 +36,17 @@ async def get_sync_status(
)
@router.post("/cancel")
async def cancel_sync(
status_service: CacheStatusService = Depends(get_cache_status_service),
):
from core.task_registry import TaskRegistry
await status_service.cancel_current_sync()
TaskRegistry.get_instance().cancel("precache-library")
await status_service.wait_for_completion()
return {"status": "cancelled"}
@router.get("/stream")
async def stream_sync_status(
status_service: CacheStatusService = Depends(get_cache_status_service),
+4 -4
View File
@@ -62,8 +62,8 @@ class AdvancedSettings(AppStruct):
delay_albums: float = 0.3
artist_discovery_warm_interval: int = 14400
artist_discovery_warm_delay: float = 0.5
artist_discovery_precache_delay: float = 0.3
artist_discovery_precache_concurrency: int = 3
artist_discovery_precache_delay: float = 0.2
artist_discovery_precache_concurrency: int = 5
memory_cache_max_entries: int = 10000
memory_cache_cleanup_interval: int = 300
cover_memory_cache_max_entries: int = 128
@@ -237,7 +237,7 @@ class AdvancedSettingsFrontend(AppStruct):
delay_albums: float = 0.3
artist_discovery_warm_interval: int = 240
artist_discovery_warm_delay: float = 0.5
artist_discovery_precache_delay: float = 0.3
artist_discovery_precache_delay: float = 0.2
memory_cache_max_entries: int = 10000
memory_cache_cleanup_interval: int = 300
cover_memory_cache_max_entries: int = 128
@@ -285,7 +285,7 @@ class AdvancedSettingsFrontend(AppStruct):
audiodb_prewarm_concurrency: int = 4
audiodb_prewarm_delay: float = 0.3
request_concurrency: int = 2
artist_discovery_precache_concurrency: int = 3
artist_discovery_precache_concurrency: int = 5
def __post_init__(self) -> None:
int_coerce_fields = [
+34 -15
View File
@@ -1,4 +1,4 @@
"""Tier 4 Business-logic service providers."""
"""Tier 4 - Business-logic service providers."""
from __future__ import annotations
@@ -91,19 +91,8 @@ def get_album_service() -> "AlbumService":
return AlbumService(lidarr_repo, mb_repo, library_db, memory_cache, disk_cache, preferences_service, audiodb_image_service, browse_queue)
@singleton
def get_request_queue() -> "RequestQueue":
from infrastructure.queue.request_queue import RequestQueue
from infrastructure.queue.queue_store import QueueStore
from infrastructure.persistence.request_history import RequestHistoryRecord
from core.config import get_settings
settings = get_settings()
lidarr_repo = get_lidarr_repository()
disk_cache = get_disk_cache()
cover_repo = get_coverart_repository()
memory_cache = get_cache()
library_db = get_library_db()
def make_on_queue_import(memory_cache, disk_cache, library_db):
"""Create the on_queue_import closure used by the request queue."""
async def on_queue_import(record: RequestHistoryRecord) -> None:
"""Invalidate caches when the queue worker detects an already-imported album."""
@@ -118,6 +107,9 @@ def get_request_queue() -> "RequestQueue":
invalidations.append(
memory_cache.delete(f"{ARTIST_INFO_PREFIX}{record.artist_mbid}")
)
invalidations.append(
disk_cache.delete_artist(record.artist_mbid)
)
await asyncio.gather(*invalidations, return_exceptions=True)
try:
await library_db.upsert_album({
@@ -133,6 +125,12 @@ def get_request_queue() -> "RequestQueue":
logger.warning("Queue import: failed to upsert album %s: %s", record.musicbrainz_id[:8], ex)
logger.info("Queue import: invalidated caches for album=%s", record.musicbrainz_id[:8])
return on_queue_import
def make_processor(lidarr_repo, memory_cache, disk_cache, cover_repo, request_history):
"""Create the processor closure used by the request queue."""
async def processor(album_mbid: str) -> dict:
result = await lidarr_repo.add_album(album_mbid)
@@ -140,7 +138,7 @@ def get_request_queue() -> "RequestQueue":
if payload and isinstance(payload, dict):
is_monitored = payload.get("monitored", False)
# Belt-andsuspenders: prefer structured signal over payload inspection
# Prefer the explicit monitored flag before falling back to the top-level result.
if not is_monitored:
is_monitored = bool(result.get("monitored"))
@@ -174,6 +172,7 @@ def get_request_queue() -> "RequestQueue":
record.artist_mbid, monitored=True, monitor_new_items=monitor_new,
)
await memory_cache.delete(f"{ARTIST_INFO_PREFIX}{record.artist_mbid}")
await disk_cache.delete_artist(record.artist_mbid)
logger.info("Applied deferred artist monitoring for %s", record.artist_mbid[:8])
break
except Exception: # noqa: BLE001
@@ -186,9 +185,29 @@ def get_request_queue() -> "RequestQueue":
return result
return processor
@singleton
def get_request_queue() -> "RequestQueue":
from infrastructure.queue.request_queue import RequestQueue
from infrastructure.queue.queue_store import QueueStore
from core.config import get_settings
settings = get_settings()
lidarr_repo = get_lidarr_repository()
disk_cache = get_disk_cache()
cover_repo = get_coverart_repository()
memory_cache = get_cache()
library_db = get_library_db()
on_queue_import = make_on_queue_import(memory_cache, disk_cache, library_db)
store = QueueStore(db_path=settings.queue_db_path)
request_history = get_request_history_store()
processor = make_processor(lidarr_repo, memory_cache, disk_cache, cover_repo, request_history)
concurrency = 2
try:
from services.preferences_service import PreferencesService
+15
View File
@@ -40,6 +40,21 @@ class TaskRegistry:
with self._lock:
self._tasks.pop(name, None)
async def cancel(self, name: str, grace_period: float = 10.0) -> None:
with self._lock:
task = self._tasks.pop(name, None)
if task is None or task.done():
return
task.cancel()
try:
await asyncio.wait_for(task, timeout=grace_period)
except asyncio.CancelledError:
return
except asyncio.TimeoutError:
logger.warning("Task '%s' did not finish within grace period", name)
async def cancel_all(self, grace_period: float = 10.0) -> None:
with self._lock:
tasks = dict(self._tasks)
+9 -6
View File
@@ -116,10 +116,12 @@ async def sync_library_periodically(
logger.info(f"Auto-syncing library (frequency: {sync_freq})")
sync_success = False
should_update_status = True
try:
result = await library_service.sync_library()
if result.status == "skipped":
logger.info("Auto-sync skipped - sync already in progress")
should_update_status = False
continue
sync_success = True
logger.info("Auto-sync completed successfully")
@@ -129,12 +131,13 @@ async def sync_library_periodically(
sync_success = False
finally:
lidarr_settings = preferences_service.get_lidarr_settings()
updated_settings = clone_with_updates(lidarr_settings, {
'last_sync': int(time()),
'last_sync_success': sync_success
})
preferences_service.save_lidarr_settings(updated_settings)
if should_update_status:
lidarr_settings = preferences_service.get_lidarr_settings()
updated_settings = clone_with_updates(lidarr_settings, {
'last_sync': int(time()),
'last_sync_success': sync_success
})
preferences_service.save_lidarr_settings(updated_settings)
except asyncio.CancelledError:
logger.info("Library sync task cancelled")
+32 -10
View File
@@ -9,28 +9,36 @@ logger = logging.getLogger(__name__)
T = TypeVar("T")
_MAX_DEDUP_RETRIES = 1
class RequestDeduplicator:
"""
Prevents duplicate concurrent requests by coalescing identical requests.
If request A is in-flight and request B arrives with the same key,
request B will wait for A's result instead of making a duplicate call.
Uses ``asyncio.shield`` so that a waiter's task cancellation propagates
cleanly without poisoning the shared future. If the leader disconnects
(``ClientDisconnectedError``), the shared future is cancelled and one
waiting follower is allowed to retry as the new leader (bounded to
``_MAX_DEDUP_RETRIES`` attempts).
"""
def __init__(self):
self._pending: dict[str, asyncio.Future[Any]] = {}
self._lock = asyncio.Lock()
async def dedupe(
self,
key: str,
coro_factory: Callable[[], Awaitable[T]]
) -> T:
retries = 0
while True:
async with self._lock:
if key in self._pending:
logger.debug(f"Deduplicating request: {key}")
future = self._pending[key]
should_execute = False
else:
@@ -41,21 +49,35 @@ class RequestDeduplicator:
if should_execute:
try:
result = await coro_factory()
future.set_result(result)
if not future.done():
future.set_result(result)
return result
except ClientDisconnectedError:
future.cancel()
if not future.done():
future.cancel()
raise
except BaseException as exc:
if not future.done():
future.set_exception(exc)
raise
except Exception as e: # noqa: BLE001
future.set_exception(e)
finally:
if not future.done():
future.cancel()
async with self._lock:
self._pending.pop(key, None)
# Follower path: shield prevents waiter cancellation from poisoning the shared future.
try:
return await future
return await asyncio.shield(future)
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
# Future was cancelled by the leader (disconnect). Retry once
# so this follower can take over as leader.
retries += 1
if retries > _MAX_DEDUP_RETRIES:
raise
continue
@@ -70,7 +92,7 @@ def deduplicate(key_func: Callable[..., str]):
"""
Decorator that deduplicates concurrent calls to the same function
with the same key.
Usage:
@deduplicate(lambda self, artist_id: f"artist:{artist_id}")
async def get_artist(self, artist_id: str) -> Artist:
+1 -1
View File
@@ -423,7 +423,7 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
async def album_is_indexed():
a = await self._get_album_by_foreign_id(musicbrainz_id)
return a and a.get("id")
return a if a and a.get("id") else None
# Only wait for auto-indexing if we just created/refreshed the artist;
# for existing artists nothing triggered new indexing, so skip the long wait.
+41 -17
View File
@@ -25,6 +25,7 @@ CIRCUIT_OPEN_CACHE_TTL = 30
DEFAULT_SIMILAR_COUNT = 15
DEFAULT_TOP_SONGS_COUNT = 10
DEFAULT_TOP_ALBUMS_COUNT = 10
_DISCOVERY_WORKER_TIMEOUT = 120
# Module-level flag survives singleton cache invalidation / instance recreation
_discovery_precache_running = False
@@ -409,6 +410,7 @@ class ArtistDiscoveryService:
delay: float = 0.5,
status_service: Any = None,
mbid_to_name: dict[str, str] | None = None,
generation: int = 0,
) -> int:
global _discovery_precache_running
if _discovery_precache_running:
@@ -420,6 +422,7 @@ class ArtistDiscoveryService:
return await self._do_precache_artist_discovery(
artist_mbids, delay=delay,
status_service=status_service, mbid_to_name=mbid_to_name,
generation=generation,
)
finally:
_discovery_precache_running = False
@@ -430,6 +433,7 @@ class ArtistDiscoveryService:
delay: float = 0.5,
status_service: Any = None,
mbid_to_name: dict[str, str] | None = None,
generation: int = 0,
) -> int:
sources: list[Literal["listenbrainz", "lastfm"]] = []
if self._lb_repo.is_configured():
@@ -447,10 +451,11 @@ class ArtistDiscoveryService:
cached_count = 0
source_fetches = 0
advanced = self._preferences_service.get_advanced_settings() if self._preferences_service else None
discovery_concurrency = getattr(advanced, 'artist_discovery_precache_concurrency', 3) if advanced else 3
discovery_concurrency = getattr(advanced, 'artist_discovery_precache_concurrency', 5) if advanced else 5
sem = asyncio.Semaphore(discovery_concurrency)
counter_lock = asyncio.Lock()
progress_counter = 0
counted_workers: set[int] = set()
async def process_artist(idx: int, mbid: str) -> bool:
nonlocal cached_count, source_fetches, progress_counter
@@ -493,18 +498,18 @@ class ArtistDiscoveryService:
async with counter_lock:
source_fetches += 1
# Sleep inside semaphore to hold slot and throttle API calls
if delay > 0:
await asyncio.sleep(delay)
if delay > 0:
await asyncio.sleep(delay)
async with counter_lock:
cached_count += 1
progress_counter += 1
local_progress = progress_counter
counted_workers.add(idx)
if status_service:
artist_name = (mbid_to_name or {}).get(mbid, mbid[:8])
await status_service.update_progress(local_progress, current_item=artist_name)
await status_service.update_progress(local_progress, current_item=artist_name, generation=generation)
if local_progress % 10 == 0:
logger.info("Discovery precache progress: %d/%d artists", local_progress, len(artist_mbids))
@@ -515,9 +520,31 @@ class ArtistDiscoveryService:
async with counter_lock:
progress_counter += 1
local_progress = progress_counter
counted_workers.add(idx)
if status_service:
artist_name = (mbid_to_name or {}).get(mbid, mbid[:8])
await status_service.update_progress(local_progress, current_item=artist_name)
await status_service.update_progress(local_progress, current_item=artist_name, generation=generation)
return False
async def process_artist_with_timeout(idx: int, mbid: str) -> bool:
nonlocal progress_counter
try:
return await asyncio.wait_for(
process_artist(idx, mbid), timeout=_DISCOVERY_WORKER_TIMEOUT
)
except asyncio.TimeoutError:
logger.warning("Discovery timed out for %s after %ds", mbid[:8], _DISCOVERY_WORKER_TIMEOUT)
async with counter_lock:
if idx not in counted_workers:
progress_counter += 1
counted_workers.add(idx)
local_progress = progress_counter
if status_service:
artist_name = (mbid_to_name or {}).get(mbid, mbid[:8])
await status_service.update_progress(
local_progress, current_item=f"{artist_name} (timed out)",
generation=generation,
)
return False
chunk = max(discovery_concurrency * 4, 20)
@@ -526,7 +553,7 @@ class ArtistDiscoveryService:
logger.info("Discovery precache cancelled by user")
break
batch = artist_mbids[i:i + chunk]
batch_tasks = [asyncio.create_task(process_artist(i + j, mbid)) for j, mbid in enumerate(batch)]
batch_tasks = [asyncio.create_task(process_artist_with_timeout(i + j, mbid)) for j, mbid in enumerate(batch)]
if batch_tasks:
await asyncio.gather(*batch_tasks, return_exceptions=True)
@@ -655,29 +682,26 @@ class ArtistDiscoveryService:
)
trimmed = lfm_albums[:count]
mbids_from_lastfm = [
a.mbid.strip().lower() for a in trimmed if a.mbid and a.mbid.strip()
]
rg_map = await self._resolve_release_groups(mbids_from_lastfm) if mbids_from_lastfm else {}
# Last.fm usually returns release-group MBIDs here, so keep them as-is
# and let the discover queue resolve the rare mismatches.
albums = []
for a in trimmed:
raw_mbid = a.mbid.strip().lower() if a.mbid and a.mbid.strip() else None
resolved_mbid = rg_map.get(raw_mbid, raw_mbid) if raw_mbid else None
albums.append(
TopAlbum(
release_group_mbid=resolved_mbid,
release_group_mbid=raw_mbid,
title=a.name,
artist_name=a.artist_name,
listen_count=a.playcount,
in_library=(
resolved_mbid in library_album_mbids
if resolved_mbid
raw_mbid in library_album_mbids
if raw_mbid
else False
),
requested=(
resolved_mbid in requested_album_mbids
if resolved_mbid
raw_mbid in requested_album_mbids
if raw_mbid
else False
),
)
+16 -1
View File
@@ -430,6 +430,7 @@ class ArtistService:
artist_info = await self._apply_audiodb_artist_images(
artist_info, artist_id, artist_info.name, allow_fetch=False,
)
await self._refresh_library_flags(artist_info)
await self._save_artist_to_cache(artist_id, artist_info)
if not future.done():
future.set_result(artist_info)
@@ -457,7 +458,21 @@ class ArtistService:
continue
rg.in_library = rg_id in library_mbids
rg.requested = rg_id in requested_mbids and not rg.in_library
artist_info.in_library = artist_info.musicbrainz_id.lower() in artist_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
if is_in_artist_mbids:
try:
lidarr_artist = await self._lidarr_repo.get_artist_details(artist_info.musicbrainz_id)
if lidarr_artist is not None:
artist_info.in_lidarr = True
artist_info.monitored = lidarr_artist.get("monitored", False)
artist_info.auto_download = lidarr_artist.get("monitor_new_items", "none") == "all"
elif not artist_info.in_lidarr:
artist_info.in_lidarr = True
except Exception: # noqa: BLE001
if not artist_info.in_lidarr:
artist_info.in_lidarr = True
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to refresh library flags: {e}")
+1 -1
View File
@@ -54,7 +54,7 @@ def detect_platform(url: str, rel_type: str) -> tuple[str, str]:
def extract_tags(mb_artist: dict[str, Any], limit: int = 10) -> list[str]:
tags = []
if mb_tags := mb_artist.get("tags", []):
tags = [tag.get("name") for tag in mb_tags if tag.get("name")][:limit]
tags = list(dict.fromkeys(tag.get("name") for tag in mb_tags if tag.get("name")))[:limit]
return tags
+66 -41
View File
@@ -29,7 +29,7 @@ class CacheSyncProgress(msgspec.Struct):
def progress_percent(self) -> int:
if self.total_items == 0:
return 0
return int((self.processed_items / self.total_items) * 100)
return min(100, int((self.processed_items / self.total_items) * 100))
class CacheStatusService:
@@ -48,6 +48,7 @@ class CacheStatusService:
def _initialize(self, sync_state_store: Optional['SyncStateStore'] = None):
self._sync_state_store = sync_state_store
self._sync_generation: int = 0
self._progress = CacheSyncProgress(
is_syncing=False,
phase=None,
@@ -115,8 +116,10 @@ class CacheStatusService:
for q in dead_queues:
self._sse_subscribers.discard(q)
async def start_sync(self, phase: str, total_items: int, total_artists: int = 0, total_albums: int = 0):
async def start_sync(self, phase: str, total_items: int, total_artists: int = 0, total_albums: int = 0) -> int:
async with self._state_lock:
self._sync_generation += 1
generation = self._sync_generation
self._cancel_event.clear()
self._last_persist_time = 0.0
self._last_broadcast_time = 0.0
@@ -151,6 +154,7 @@ class CacheStatusService:
logger.warning(f"Failed to persist sync state: {e}")
await self.broadcast_progress()
return generation
_BROADCAST_THROTTLE_SECONDS = 0.3
@@ -159,9 +163,12 @@ class CacheStatusService:
processed: int,
current_item: Optional[str] = None,
processed_artists: Optional[int] = None,
processed_albums: Optional[int] = None
processed_albums: Optional[int] = None,
generation: int = 0,
):
async with self._state_lock:
if generation and generation != self._sync_generation:
return
if processed >= self._progress.processed_items:
self._progress.processed_items = processed
self._progress.current_item = current_item
@@ -177,8 +184,10 @@ class CacheStatusService:
self._last_broadcast_time = now
await self.broadcast_progress()
async def update_phase(self, phase: str, total_items: int):
async def update_phase(self, phase: str, total_items: int, generation: int = 0):
async with self._state_lock:
if generation and generation != self._sync_generation:
return
self._progress.phase = phase
self._progress.total_items = total_items
self._progress.processed_items = 0
@@ -201,9 +210,11 @@ class CacheStatusService:
await self.broadcast_progress()
async def skip_phase(self, phase: str):
async def skip_phase(self, phase: str, generation: int = 0):
"""Broadcast a phase with 0 items so the frontend sees it as skipped."""
async with self._state_lock:
if generation and generation != self._sync_generation:
return
self._progress.phase = phase
self._progress.total_items = 0
self._progress.processed_items = 0
@@ -218,11 +229,13 @@ class CacheStatusService:
_PERSIST_INTERVAL_SECONDS = 5.0
_PERSIST_ITEM_INTERVAL = 10
async def persist_progress(self, force: bool = False):
async def persist_progress(self, force: bool = False, generation: int = 0):
if not self._progress.is_syncing:
return
if self.is_cancelled():
return
if generation and generation != self._sync_generation:
return
self._persist_item_counter += 1
now = time.time()
@@ -249,10 +262,12 @@ class CacheStatusService:
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to persist progress: {e}")
async def complete_sync(self, error_message: Optional[str] = None):
async def complete_sync(self, error_message: Optional[str] = None, generation: int = 0):
async with self._state_lock:
if not self._progress.is_syncing:
return
if generation and generation != self._sync_generation:
return
is_success = error_message is None
status = 'completed' if is_success else 'failed'
logger.info(f"Cache sync {status}: {self._progress.phase}")
@@ -299,37 +314,36 @@ class CacheStatusService:
async def cancel_current_sync(self):
async with self._state_lock:
if self._progress.is_syncing:
logger.warning(f"Cancelling in-progress sync: phase={self._progress.phase}, progress={self._progress.processed_items}/{self._progress.total_items}")
self._cancel_event.set()
logger.warning(f"Cancelling sync: phase={self._progress.phase}, progress={self._progress.processed_items}/{self._progress.total_items}")
self._cancel_event.set()
if self._sync_state_store:
try:
await self._sync_state_store.save_sync_state(
status='cancelled',
phase=self._progress.phase,
total_artists=self._progress.total_artists,
processed_artists=self._progress.processed_artists,
total_albums=self._progress.total_albums,
processed_albums=self._progress.processed_albums,
started_at=self._progress.started_at
)
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to persist cancellation: {e}")
if self._sync_state_store and self._progress.is_syncing:
try:
await self._sync_state_store.save_sync_state(
status='cancelled',
phase=self._progress.phase,
total_artists=self._progress.total_artists,
processed_artists=self._progress.processed_artists,
total_albums=self._progress.total_albums,
processed_albums=self._progress.processed_albums,
started_at=self._progress.started_at
)
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to persist cancellation: {e}")
self._progress = CacheSyncProgress(
is_syncing=False,
phase=None,
total_items=0,
processed_items=0,
current_item=None,
started_at=None,
error_message=None,
total_artists=0,
processed_artists=0,
total_albums=0,
processed_albums=0
)
self._progress = CacheSyncProgress(
is_syncing=False,
phase=None,
total_items=0,
processed_items=0,
current_item=None,
started_at=None,
error_message=None,
total_artists=0,
processed_artists=0,
total_albums=0,
processed_albums=0
)
await self.broadcast_progress()
@@ -343,9 +357,9 @@ class CacheStatusService:
task = self._current_task
if task and not task.done():
try:
await asyncio.wait_for(task, timeout=5.0)
await asyncio.wait_for(task, timeout=30.0)
except asyncio.TimeoutError:
logger.warning("Sync task did not complete within timeout, forcing cancellation")
logger.warning("Sync task did not complete within 30s timeout, forcing cancellation")
if not task.done():
task.cancel()
except Exception as e: # noqa: BLE001
@@ -365,11 +379,22 @@ class CacheStatusService:
f"artists={state.get('processed_artists')}/{state.get('total_artists')}, "
f"albums={state.get('processed_albums')}/{state.get('total_albums')}")
phase = state.get('phase')
if phase == 'albums':
total_items = state.get('total_albums')
processed_items = state.get('processed_albums')
elif phase == 'audiodb_prewarm':
total_items = 0
processed_items = 0
else:
total_items = state.get('total_artists')
processed_items = state.get('processed_artists')
self._progress = CacheSyncProgress(
is_syncing=True,
phase=state.get('phase'),
total_items=state.get('total_albums') if state.get('phase') == 'albums' else state.get('total_artists'),
processed_items=state.get('processed_albums') if state.get('phase') == 'albums' else state.get('processed_artists'),
phase=phase,
total_items=total_items,
processed_items=processed_items,
current_item=state.get('current_item'),
started_at=state.get('started_at'),
error_message=None,
+14
View File
@@ -417,10 +417,23 @@ class LibraryService:
exc = t.exception()
if exc:
logger.error(f"Precache task failed: {exc}")
task_success = False
else:
task_success = not t.cancelled()
except asyncio.CancelledError:
logger.info("Precache task was cancelled")
task_success = False
finally:
status_service.set_current_task(None)
try:
lidarr_settings = self._preferences_service.get_lidarr_settings()
if sync_started_at >= (lidarr_settings.last_sync or 0):
updated = clone_with_updates(lidarr_settings, {
'last_sync_success': task_success,
})
self._preferences_service.save_lidarr_settings(updated)
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to update last_sync_success: {e}")
task.add_done_callback(on_task_done)
status_service.set_current_task(task)
@@ -428,6 +441,7 @@ class LibraryService:
logger.info(f"Library sync complete: {len(artists)} artists, {len(albums)} albums")
self._update_last_sync_timestamp()
sync_started_at = self._preferences_service.get_lidarr_settings().last_sync or 0
result = SyncLibraryResponse(
status='success',
+12 -8
View File
@@ -33,6 +33,7 @@ class AlbumPhase:
status_service: CacheStatusService,
library_album_mbids: dict[str, Any] = None,
offset: int = 0,
generation: int = 0,
) -> None:
from core.dependencies import get_album_service
logger.info(f"Pre-caching {len(release_group_ids)} new/missing release-groups")
@@ -47,11 +48,11 @@ class AlbumPhase:
cache_key = f"{ALBUM_INFO_PREFIX}{rgid}"
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)
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)
metadata_fetched = True
else:
await status_service.update_progress(index + 1, f"Cached: {rgid[:8]}...", processed_albums=offset + index + 1)
await status_service.update_progress(index + 1, f"Cached: {rgid[:8]}...", processed_albums=offset + index + 1, generation=generation)
if rgid.lower() in monitored_mbids:
cache_filename = get_cache_filename(f"rg_{rgid}", "500")
file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin"
@@ -73,7 +74,8 @@ class AlbumPhase:
metadata_fetched = 0
covers_fetched = 0
consecutive_slow_batches = 0
for i in range(0, len(release_group_ids), batch_size):
i = 0
while i < len(release_group_ids):
if status_service.is_cancelled():
logger.info("Album pre-caching cancelled by user")
break
@@ -98,7 +100,7 @@ class AlbumPhase:
processed_mbids.append(rgid)
if processed_mbids:
await self._sync_state_store.mark_items_processed_batch('album', processed_mbids)
await status_service.persist_progress()
await status_service.persist_progress(generation=generation)
batch_duration = time.time() - batch_start
avg_time_per_item = batch_duration / len(batch) if batch else 1.0
if avg_time_per_item > 1.5:
@@ -114,9 +116,11 @@ class AlbumPhase:
if avg_time_per_item < 0.8 and batch_size < max_batch:
batch_size = min(batch_size + 1, max_batch)
logger.debug(f"Increasing batch size to {batch_size} (fast: {avg_time_per_item:.2f}s/item)")
if (i + batch_size) % 30 == 0 or (i + batch_size) >= len(release_group_ids):
percent = int((min(i + batch_size, len(release_group_ids)) / len(release_group_ids)) * 100)
logger.info(f"Album progress: {min(i + batch_size, len(release_group_ids))}/{len(release_group_ids)} ({percent}%) - metadata: {metadata_fetched}, covers: {covers_fetched} [batch: {batch_size}]")
next_i = i + len(batch)
if next_i % 30 == 0 or next_i >= len(release_group_ids):
percent = int((min(next_i, len(release_group_ids)) / len(release_group_ids)) * 100)
logger.info(f"Album progress: {min(next_i, len(release_group_ids))}/{len(release_group_ids)} ({percent}%) - metadata: {metadata_fetched}, covers: {covers_fetched} [batch: {batch_size}]")
i = next_i
await asyncio.sleep(advanced_settings.delay_albums)
await status_service.persist_progress(force=True)
await status_service.persist_progress(force=True, generation=generation)
logger.info(f"Album pre-caching complete: metadata fetched={metadata_fetched}, covers fetched={covers_fetched}, total processed={len(release_group_ids)}")
+21 -7
View File
@@ -36,18 +36,32 @@ class ArtistPhase:
library_artist_mbids: set[str] = None,
library_album_mbids: dict[str, Any] = None,
offset: int = 0,
generation: int = 0,
) -> None:
logger.info(f"Pre-caching metadata+images for {len(artists)} artists")
from core.dependencies import get_artist_service
from infrastructure.validators import is_unknown_mbid
artist_service = get_artist_service()
seen_mbids: set[str] = set()
unique_artists: list[dict] = []
for a in artists:
mbid = a.get('mbid')
if not mbid or is_unknown_mbid(mbid):
unique_artists.append(a)
elif mbid.lower() not in seen_mbids:
seen_mbids.add(mbid.lower())
unique_artists.append(a)
if len(unique_artists) < len(artists):
logger.info("Deduplicated %d artists to %d unique", len(artists), len(unique_artists))
artists = unique_artists
async def cache_artist(artist: dict, index: int) -> str:
mbid = artist.get('mbid')
try:
artist_name = artist.get('name', 'Unknown')
if is_unknown_mbid(mbid):
await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1)
await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1, generation=generation)
return mbid
artist_cache_key = f"{ARTIST_INFO_PREFIX}{mbid}"
cached_artist = await artist_service._cache.get(artist_cache_key)
@@ -64,18 +78,18 @@ class ArtistPhase:
file_path_500 = self._cover_repo.cache_dir / f"{cache_filename_500}.bin"
if file_path_250.exists() and file_path_500.exists():
logger.debug(f"Artist images for {artist_name} already cached, skipping")
await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1)
await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1, generation=generation)
return mbid
await status_service.update_progress(index + 1, f"Fetching images for {artist_name}", processed_artists=offset + index + 1)
await status_service.update_progress(index + 1, f"Fetching images for {artist_name}", processed_artists=offset + index + 1, generation=generation)
if not file_path_250.exists():
await self._cover_repo.get_artist_image(mbid, size=250)
if not file_path_500.exists():
await self._cover_repo.get_artist_image(mbid, size=500)
await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1)
await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1, generation=generation)
return mbid
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to cache artist {artist.get('name')} (mbid: {mbid}): {e}", exc_info=True)
await status_service.update_progress(index + 1, f"Failed: {artist.get('name', 'Unknown')}", processed_artists=offset + index + 1)
await status_service.update_progress(index + 1, f"Failed: {artist.get('name', 'Unknown')}", processed_artists=offset + index + 1, generation=generation)
return mbid
advanced_settings = self._preferences_service.get_advanced_settings()
@@ -97,9 +111,9 @@ class ArtistPhase:
processed_mbids.append(result)
if processed_mbids:
await self._sync_state_store.mark_items_processed_batch('artist', processed_mbids)
await status_service.persist_progress()
await status_service.persist_progress(generation=generation)
await asyncio.sleep(advanced_settings.delay_artist)
await status_service.persist_progress(force=True)
await status_service.persist_progress(force=True, generation=generation)
logger.info("Artist metadata+image pre-caching complete")
await self._cache_artist_genres(artists)
+7 -6
View File
@@ -179,15 +179,16 @@ class AudioDBPhase:
artists: list[dict],
albums: list[Any],
status_service: CacheStatusService,
generation: int = 0,
) -> None:
if self._audiodb_image_service is None:
await status_service.skip_phase('audiodb_prewarm')
await status_service.skip_phase('audiodb_prewarm', generation=generation)
return
settings = self._preferences_service.get_advanced_settings()
if not settings.audiodb_enabled:
logger.info("AudioDB pre-warming skipped (audiodb_enabled=false)")
await status_service.skip_phase('audiodb_prewarm')
await status_service.skip_phase('audiodb_prewarm', generation=generation)
return
concurrency = settings.audiodb_prewarm_concurrency
@@ -197,7 +198,7 @@ class AudioDBPhase:
total = len(needed_artists) + len(needed_albums)
if total == 0:
logger.info("AudioDB prewarm: all items already cached")
await status_service.skip_phase('audiodb_prewarm')
await status_service.skip_phase('audiodb_prewarm', generation=generation)
return
original_total = len(artists) + len(albums)
@@ -206,7 +207,7 @@ class AudioDBPhase:
"Phase 5 (AudioDB): Pre-warming %d items (%d artists, %d albums), %.0f%% already cached, concurrency=%d delay=%.1fs",
total, len(needed_artists), len(needed_albums), initial_hit_rate, concurrency, inter_item_delay,
)
await status_service.update_phase('audiodb_prewarm', total)
await status_service.update_phase('audiodb_prewarm', total, generation=generation)
needed_artists = self.sort_by_cover_priority(needed_artists, "artist")
needed_albums = self.sort_by_cover_priority(needed_albums, "album")
@@ -249,7 +250,7 @@ class AudioDBPhase:
processed += 1
local_processed = processed
snap_ok, snap_fail = bytes_ok, bytes_fail
await status_service.update_progress(local_processed, f"AudioDB: {name}")
await status_service.update_progress(local_processed, f"AudioDB: {name}", generation=generation)
if local_processed % _AUDIODB_PREWARM_LOG_INTERVAL == 0:
logger.info(
@@ -291,7 +292,7 @@ class AudioDBPhase:
processed += 1
local_processed = processed
snap_ok, snap_fail = bytes_ok, bytes_fail
await status_service.update_progress(local_processed, f"AudioDB: {album_name or 'Unknown'}")
await status_service.update_progress(local_processed, f"AudioDB: {album_name or 'Unknown'}", generation=generation)
if local_processed % _AUDIODB_PREWARM_LOG_INTERVAL == 0:
logger.info(
+40 -33
View File
@@ -98,9 +98,13 @@ class LibraryPrecacheService:
if not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
await asyncio.wait_for(asyncio.shield(task), timeout=15)
except asyncio.CancelledError:
if asyncio.current_task().cancelling() > 0:
raise # outer task cancelled; propagate
# inner task exited cleanly after cancel
except (asyncio.TimeoutError, Exception):
logger.warning("Precache task did not exit within 15s of cancel")
await status_service.complete_sync(str(exc))
raise ExternalServiceError(str(exc))
@@ -141,6 +145,7 @@ class LibraryPrecacheService:
async def _do_precache(self, artists: list[dict], albums: list[Any], status_service: CacheStatusService, resume: bool = False) -> None:
from core.dependencies import get_album_service
generation = 0
try:
processed_artists: set[str] = set()
processed_albums: set[str] = set()
@@ -152,9 +157,9 @@ class LibraryPrecacheService:
processed_albums = await self._sync_state_store.get_processed_items('album')
state = await self._sync_state_store.get_sync_state()
if state and state.get('phase') == 'albums':
if state and state.get('phase') in ('albums', 'audiodb_prewarm'):
skip_artists = True
logger.info(f"Resuming from albums phase, {len(processed_albums)} albums already processed")
logger.info(f"Resuming from {state.get('phase')} phase, {len(processed_albums)} albums already processed")
else:
logger.info(f"Resuming from artists phase, {len(processed_artists)} artists already processed")
@@ -172,23 +177,26 @@ class LibraryPrecacheService:
remaining_artists = [a for a in artists if a.get('mbid') not in processed_artists]
logger.info(f"Phase 1: Caching {len(remaining_artists)} artist metadata + images ({len(processed_artists)} already done)")
if remaining_artists:
await status_service.start_sync('artists', len(remaining_artists), total_artists=total_artists, total_albums=total_albums)
await self._artist_phase.precache_artist_images(remaining_artists, status_service, library_artist_mbids, library_album_mbids, len(processed_artists))
generation = await status_service.start_sync('artists', len(remaining_artists), total_artists=total_artists, total_albums=total_albums)
await self._artist_phase.precache_artist_images(remaining_artists, status_service, library_artist_mbids, library_album_mbids, len(processed_artists), generation=generation)
else:
await status_service.start_sync('artists', 0, total_artists=total_artists, total_albums=total_albums)
await status_service.skip_phase('artists')
generation = await status_service.start_sync('artists', 0, total_artists=total_artists, total_albums=total_albums)
await status_service.skip_phase('artists', generation=generation)
else:
generation = await status_service.start_sync('albums', 0, total_artists=total_artists, total_albums=total_albums)
logger.info("Resuming sync, skipping artists/discovery phases")
if status_service.is_cancelled():
logger.info("Pre-cache cancelled after Phase 1")
return
if self._artist_discovery_service and not skip_artists:
artist_mbids = [
artist_mbids = list(dict.fromkeys(
a.get('mbid') for a in artists
if a.get('mbid') and not a.get('mbid', '').startswith('unknown_')
]
))
if artist_mbids:
logger.info(f"Phase 1.5: Pre-caching discovery data (popular albums/songs/similar) for {len(artist_mbids)} library artists")
await status_service.update_phase('discovery', len(artist_mbids))
await status_service.update_phase('discovery', len(artist_mbids), generation=generation)
mbid_to_name = {
a.get('mbid'): a.get('name', a.get('mbid', '')[:8])
for a in artists if a.get('mbid')
@@ -199,13 +207,14 @@ class LibraryPrecacheService:
await self._artist_discovery_service.precache_artist_discovery(
artist_mbids, delay=precache_delay,
status_service=status_service, mbid_to_name=mbid_to_name,
generation=generation,
)
except Exception as e: # noqa: BLE001
logger.warning(f"Discovery precache failed (non-fatal): {e}")
else:
await status_service.skip_phase('discovery')
await status_service.skip_phase('discovery', generation=generation)
elif not skip_artists:
await status_service.skip_phase('discovery')
await status_service.skip_phase('discovery', generation=generation)
if status_service.is_cancelled():
logger.info("Pre-cache cancelled after Phase 1.5")
@@ -253,27 +262,25 @@ class LibraryPrecacheService:
f"{already_cached} already cached, {len(processed_albums)} from previous run"
)
if items_to_process:
await status_service.update_phase('albums', len(items_to_process))
await self._album_phase.precache_album_data(items_to_process, monitored_mbids, status_service, library_album_mbids, len(processed_albums))
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)
else:
await status_service.skip_phase('albums')
await status_service.skip_phase('albums', generation=generation)
if status_service.is_cancelled():
logger.info("Pre-cache cancelled after albums phase")
return
try:
logger.info("Starting AudioDB image prewarm...")
await self._audiodb_phase.precache_audiodb_data(artists, albums, status_service, generation=generation)
logger.info("AudioDB image prewarm complete")
except Exception as e: # noqa: BLE001
logger.warning(f"AudioDB pre-warming failed (non-fatal): {e}")
if not status_service.is_cancelled():
await status_service.complete_sync()
logger.info("Library resource pre-caching complete (core phases done)")
try:
audiodb_timeout = self._preferences_service.get_advanced_settings().sync_max_timeout_hours * 3600
logger.info("Starting AudioDB image prewarm as background enhancement...")
await asyncio.wait_for(
self._audiodb_phase.precache_audiodb_data(artists, albums, status_service),
timeout=audiodb_timeout,
)
logger.info("AudioDB image prewarm complete")
except asyncio.TimeoutError:
logger.warning("AudioDB pre-warming timed out (non-fatal)")
except Exception as e: # noqa: BLE001
logger.warning(f"AudioDB pre-warming failed (non-fatal): {e}")
await status_service.complete_sync(generation=generation)
logger.info("Library resource pre-caching complete")
else:
logger.info("Library resource pre-caching complete (cancelled)")
except Exception as e:
@@ -281,4 +288,4 @@ class LibraryPrecacheService:
raise
finally:
if status_service.is_syncing():
await status_service.complete_sync()
await status_service.complete_sync(generation=generation)
@@ -0,0 +1,181 @@
import asyncio
import pytest
from infrastructure.http.deduplication import RequestDeduplicator
@pytest.mark.anyio
async def test_follower_task_cancellation_propagates():
"""When a follower's own task is cancelled, CancelledError must propagate."""
dedup = RequestDeduplicator()
leader_started = asyncio.Event()
leader_release = asyncio.Event()
async def slow_coro():
leader_started.set()
await leader_release.wait()
return "result"
async def run_follower():
await leader_started.wait()
return await dedup.dedupe("key", slow_coro)
leader_task = asyncio.create_task(dedup.dedupe("key", slow_coro))
await asyncio.sleep(0)
follower_task = asyncio.create_task(run_follower())
await asyncio.sleep(0)
follower_task.cancel()
with pytest.raises(asyncio.CancelledError):
await follower_task
leader_release.set()
result = await leader_task
assert result == "result"
@pytest.mark.anyio
async def test_leader_exception_propagates_to_follower():
"""When the leader raises, the follower receives the same exception."""
dedup = RequestDeduplicator()
leader_started = asyncio.Event()
async def failing_coro():
leader_started.set()
await asyncio.sleep(0)
raise ValueError("boom")
async def run_follower():
await leader_started.wait()
return await dedup.dedupe("key", failing_coro)
leader_task = asyncio.create_task(dedup.dedupe("key", failing_coro))
await asyncio.sleep(0)
follower_task = asyncio.create_task(run_follower())
with pytest.raises(ValueError, match="boom"):
await leader_task
with pytest.raises(ValueError, match="boom"):
await follower_task
@pytest.mark.anyio
async def test_leader_cancellation_follower_retries_as_leader():
"""When leader is cancelled, follower retries as new leader (bounded retry)."""
dedup = RequestDeduplicator()
leader_started = asyncio.Event()
call_count = 0
async def coro():
nonlocal call_count
call_count += 1
if call_count == 1:
leader_started.set()
await asyncio.sleep(60)
return "never"
return "retried"
async def run_follower():
await leader_started.wait()
return await dedup.dedupe("key", coro)
leader_task = asyncio.create_task(dedup.dedupe("key", coro))
await asyncio.sleep(0)
follower_task = asyncio.create_task(run_follower())
await asyncio.sleep(0)
leader_task.cancel()
with pytest.raises(asyncio.CancelledError):
await leader_task
result = await follower_task
assert result == "retried"
assert call_count == 2
@pytest.mark.anyio
async def test_concurrent_followers_coalesce():
"""Multiple followers all receive the same result from one leader execution."""
dedup = RequestDeduplicator()
call_count = 0
leader_started = asyncio.Event()
release_leader = asyncio.Event()
async def counted_coro():
nonlocal call_count
call_count += 1
leader_started.set()
await release_leader.wait()
return "shared-result"
async def run_follower():
await leader_started.wait()
return await dedup.dedupe("key", counted_coro)
leader_task = asyncio.create_task(dedup.dedupe("key", counted_coro))
await asyncio.sleep(0)
followers = [asyncio.create_task(run_follower()) for _ in range(5)]
# Let all followers register as waiters on the shared future
for _ in range(10):
await asyncio.sleep(0)
release_leader.set()
results = await asyncio.gather(leader_task, *followers)
assert all(r == "shared-result" for r in results)
assert call_count == 1
@pytest.mark.anyio
async def test_disconnect_leader_follower_retries_as_leader():
"""When leader disconnects, one follower retries as the new leader."""
from core.exceptions import ClientDisconnectedError
dedup = RequestDeduplicator()
follower_registered = asyncio.Event()
expected_result = ("image-bytes", "image/png", "source")
leader_error = None
async def leader_coro():
await follower_registered.wait()
raise ClientDisconnectedError("leader disconnected")
async def run_leader():
nonlocal leader_error
try:
await dedup.dedupe("key1", leader_coro)
except ClientDisconnectedError as e:
leader_error = e
async def follower_coro():
return expected_result
async def run_follower():
await asyncio.sleep(0)
follower_registered.set()
return await dedup.dedupe("key1", follower_coro)
leader_task = asyncio.create_task(run_leader())
await asyncio.sleep(0)
follower_task = asyncio.create_task(run_follower())
await asyncio.gather(leader_task, follower_task)
assert isinstance(leader_error, ClientDisconnectedError)
assert follower_task.result() == expected_result
@pytest.mark.anyio
async def test_key_cleanup_after_completion():
"""After dedupe completes, the key is removed from _pending."""
dedup = RequestDeduplicator()
async def simple_coro():
return 42
result = await dedup.dedupe("key", simple_coro)
assert result == 42
assert "key" not in dedup._pending
@@ -0,0 +1,126 @@
"""Test that album_is_indexed returns a dict, not an int.
Regression test for: 'argument of type int is not iterable' when
album_is_indexed returned a.get("id") (int) instead of the album dict.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from repositories.lidarr.album import LidarrAlbumRepository
@pytest.fixture
def album_repo():
settings = MagicMock()
settings.lidarr_url = "http://lidarr:8686"
settings.lidarr_api_key = "test-key"
settings.quality_profile_id = 1
cache = AsyncMock()
cache.get.return_value = None
cache.set.return_value = None
cache.delete.return_value = None
cache.clear_prefix.return_value = 0
http_client = AsyncMock()
return LidarrAlbumRepository(settings=settings, http_client=http_client, cache=cache)
class TestAlbumIsIndexedReturnType:
"""The album_is_indexed closure must return a dict (or None), never an int."""
@pytest.mark.asyncio
async def test_add_album_new_album_existing_artist_no_type_error(self, album_repo):
"""When album needs POST-adding for an existing artist, album_obj must be a dict.
Before the fix, album_is_indexed returned a.get("id") (int),
causing 'argument of type int is not iterable' at the 'id not in album_obj' check.
"""
artist_repo = AsyncMock()
artist_repo._ensure_artist_exists.return_value = (
{"id": 42, "artistName": "MCR", "foreignArtistId": "artist-1",
"qualityProfileId": 1, "metadataProfileId": 1, "rootFolderPath": "/music"},
False, # artist NOT created (already existed)
)
album_dict = {
"id": 99,
"title": "Greatest Hits",
"foreignAlbumId": "ae700a64-0890-457e-9440-51cdb06d58e1",
"monitored": True,
"statistics": {"trackFileCount": 0},
"artist": {"foreignArtistId": "artist-1", "artistName": "MCR"},
}
lookup_response = [{
"foreignAlbumId": "ae700a64-0890-457e-9440-51cdb06d58e1",
"title": "Greatest Hits",
"albumType": "Album",
"secondaryTypes": [],
"artist": {"mbId": "artist-1", "foreignArtistId": "artist-1", "artistName": "MCR"},
}]
album_repo._get = AsyncMock(side_effect=[
lookup_response, # album/lookup
[{"id": 42}], # /api/v1/artist (existing artist check)
[album_dict], # /api/v1/album?artistId=42 (pre_add_monitored_ids)
[{"id": 1}], # /api/v1/qualityprofile
album_dict, # POST /api/v1/album response (via _post)
[album_dict], # /api/v1/album?artistId=42 (unmonitor check)
])
# First call: not found. Second call onward: found (after POST).
album_repo._get_album_by_foreign_id = AsyncMock(side_effect=[
None, # initial check — album not in Lidarr yet
album_dict, # after POST — album now indexed
album_dict, # final fetch
])
album_repo._post = AsyncMock(return_value=album_dict)
album_repo._put = AsyncMock(return_value=album_dict)
result = await album_repo.add_album(
"ae700a64-0890-457e-9440-51cdb06d58e1", artist_repo
)
assert isinstance(result, dict)
assert "payload" in result
payload = result["payload"]
assert isinstance(payload, dict), (
f"payload should be dict, got {type(payload).__name__}. "
"This was the original bug — album_is_indexed returned int."
)
assert payload.get("id") == 99
@pytest.mark.asyncio
async def test_add_album_existing_album_no_regression(self, album_repo):
"""Existing monitored+downloaded album returns immediately (no type error)."""
artist_repo = AsyncMock()
artist_repo._ensure_artist_exists.return_value = (
{"id": 42, "artistName": "MCR", "foreignArtistId": "artist-1",
"qualityProfileId": 1, "metadataProfileId": 1, "rootFolderPath": "/music"},
False,
)
album_dict = {
"id": 50,
"title": "Three Cheers",
"foreignAlbumId": "bbbb-cccc",
"monitored": True,
"statistics": {"trackFileCount": 12},
"artist": {"foreignArtistId": "artist-1"},
}
album_repo._get = AsyncMock(side_effect=[
[{"foreignAlbumId": "bbbb-cccc", "title": "Three Cheers", "albumType": "Album",
"secondaryTypes": [],
"artist": {"mbId": "artist-1", "foreignArtistId": "artist-1", "artistName": "MCR"}}],
[{"id": 42}], # existing artist
[album_dict], # albums_before for pre_add_monitored_ids
])
album_repo._get_album_by_foreign_id = AsyncMock(return_value=album_dict)
album_repo._put = AsyncMock()
album_repo._post = AsyncMock()
result = await album_repo.add_album("bbbb-cccc", artist_repo)
assert isinstance(result, dict)
assert "already downloaded" in result["message"]
assert isinstance(result["payload"], dict)
@@ -0,0 +1,95 @@
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
import pytest
from unittest.mock import AsyncMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.artists import router
from core.dependencies import get_artist_service, get_artist_discovery_service, get_artist_enrichment_service
from models.artist import ArtistInfo, ReleaseItem
VALID_MBID = "f4a31f0a-51dd-4fa7-986d-3095c40c5ed9"
def _minimal_artist_info(mbid: str = VALID_MBID) -> ArtistInfo:
return ArtistInfo(
name="Test Artist",
musicbrainz_id=mbid,
albums=[ReleaseItem(id="rg-1", title="Album One", type="Album", year=2024)],
singles=[],
eps=[],
release_group_count=1,
in_library=False,
)
@pytest.fixture
def mock_artist_service():
mock = AsyncMock()
mock.get_artist_info_basic = AsyncMock(return_value=_minimal_artist_info())
mock.get_artist_info = AsyncMock(side_effect=AssertionError(
"get_artist_info should NOT be called — route must use get_artist_info_basic"
))
mock.get_artist_releases = AsyncMock()
mock.get_artist_extended_info = AsyncMock()
return mock
@pytest.fixture
def mock_discovery_service():
return AsyncMock()
@pytest.fixture
def mock_enrichment_service():
return AsyncMock()
@pytest.fixture
def client(mock_artist_service, mock_discovery_service, mock_enrichment_service):
app = FastAPI()
app.include_router(router, prefix="/api/v1")
app.dependency_overrides[get_artist_service] = lambda: mock_artist_service
app.dependency_overrides[get_artist_discovery_service] = lambda: mock_discovery_service
app.dependency_overrides[get_artist_enrichment_service] = lambda: mock_enrichment_service
return TestClient(app)
class TestGetArtistBasicRoute:
def test_get_artist_calls_basic_method(self, client, mock_artist_service):
response = client.get(f"/api/v1/artists/{VALID_MBID}")
assert response.status_code == 200
mock_artist_service.get_artist_info_basic.assert_awaited_once_with(VALID_MBID)
def test_get_artist_does_not_call_full_method(self, client, mock_artist_service):
response = client.get(f"/api/v1/artists/{VALID_MBID}")
assert response.status_code == 200
mock_artist_service.get_artist_info.assert_not_awaited()
def test_get_artist_returns_valid_response(self, client):
response = client.get(f"/api/v1/artists/{VALID_MBID}")
body = response.json()
assert response.status_code == 200
assert body["name"] == "Test Artist"
assert body["musicbrainz_id"] == VALID_MBID
assert body["description"] is None
assert body["image"] is None
assert len(body["albums"]) == 1
assert body["release_group_count"] == 1
def test_get_artist_value_error_returns_400(self, client, mock_artist_service):
mock_artist_service.get_artist_info_basic = AsyncMock(
side_effect=ValueError("Invalid artist request")
)
response = client.get(f"/api/v1/artists/{VALID_MBID}")
assert response.status_code == 400
@@ -0,0 +1,83 @@
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
import pytest
from unittest.mock import AsyncMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.artists import router
from api.v1.schemas.artist import ArtistReleases
from core.dependencies import get_artist_service, get_artist_discovery_service, get_artist_enrichment_service
from models.artist import ReleaseItem
VALID_MBID = "f4a31f0a-51dd-4fa7-986d-3095c40c5ed9"
@pytest.fixture
def mock_artist_service():
mock = AsyncMock()
mock.get_artist_releases = AsyncMock(
return_value=ArtistReleases(
albums=[ReleaseItem(id="rg-2", title="Album Two", type="Album", year=2023)],
singles=[],
eps=[],
total_count=120,
has_more=True,
)
)
mock.get_artist_info_basic = AsyncMock()
return mock
@pytest.fixture
def mock_discovery_service():
return AsyncMock()
@pytest.fixture
def mock_enrichment_service():
return AsyncMock()
@pytest.fixture
def client(mock_artist_service, mock_discovery_service, mock_enrichment_service):
app = FastAPI()
app.include_router(router, prefix="/api/v1")
app.dependency_overrides[get_artist_service] = lambda: mock_artist_service
app.dependency_overrides[get_artist_discovery_service] = lambda: mock_discovery_service
app.dependency_overrides[get_artist_enrichment_service] = lambda: mock_enrichment_service
return TestClient(app)
class TestGetArtistReleasesRoute:
def test_pagination_params_forwarded(self, client, mock_artist_service):
response = client.get(f"/api/v1/artists/{VALID_MBID}/releases?offset=50&limit=50")
assert response.status_code == 200
mock_artist_service.get_artist_releases.assert_awaited_once_with(VALID_MBID, 50, 50)
def test_has_more_flag_propagated(self, client):
response = client.get(f"/api/v1/artists/{VALID_MBID}/releases?offset=0&limit=50")
body = response.json()
assert response.status_code == 200
assert body["has_more"] is True
assert body["total_count"] == 120
def test_default_params(self, client, mock_artist_service):
response = client.get(f"/api/v1/artists/{VALID_MBID}/releases")
assert response.status_code == 200
mock_artist_service.get_artist_releases.assert_awaited_once_with(VALID_MBID, 0, 50)
def test_value_error_returns_400(self, client, mock_artist_service):
mock_artist_service.get_artist_releases = AsyncMock(
side_effect=ValueError("Invalid artist request")
)
response = client.get(f"/api/v1/artists/{VALID_MBID}/releases")
assert response.status_code == 400
@@ -0,0 +1,125 @@
"""Tests that the basic artist info path returns correctly and skips Wikidata enrichment."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from models.artist import ArtistInfo, ReleaseItem
from services.artist_service import ArtistService
ARTIST_MBID = "f4a31f0a-51dd-4fa7-986d-3095c40c5ed9"
def _make_mb_artist() -> dict:
return {
"id": ARTIST_MBID,
"name": "Test Artist",
"type": "Group",
"country": "GB",
"disambiguation": "",
"life-span": {"begin": "2000", "end": None, "ended": "false"},
"tag-list": [{"name": "rock", "count": 5}],
"alias-list": [],
"url-relation-list": [],
"release-group-list": [
{
"id": "rg-001",
"title": "First Album",
"type": "Album",
"primary-type": "Album",
"secondary-type-list": [],
"first-release-date": "2020-01-01",
}
],
"release-group-count": 1,
}
def _make_service(*, cached_artist: ArtistInfo | None = None) -> tuple[ArtistService, AsyncMock]:
mb_repo = AsyncMock()
mb_repo.get_artist_by_id = AsyncMock(return_value=_make_mb_artist())
lidarr_repo = MagicMock()
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_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock()
wikidata_repo.get_wikidata_info = AsyncMock(
side_effect=AssertionError("Wikidata should NOT be called in basic path")
)
prefs = MagicMock()
prefs.get_preferences.return_value = MagicMock(
primary_types=["Album", "Single", "EP"],
secondary_types=[],
)
prefs.get_advanced_settings.return_value = MagicMock(
cache_ttl_artist_library=21600,
cache_ttl_artist_non_library=3600,
)
memory_cache = AsyncMock()
memory_cache.get = AsyncMock(return_value=cached_artist)
memory_cache.set = AsyncMock()
disk_cache = AsyncMock()
disk_cache.get_artist = AsyncMock(return_value=None)
disk_cache.set_artist = AsyncMock()
svc = ArtistService(
mb_repo=mb_repo,
lidarr_repo=lidarr_repo,
wikidata_repo=wikidata_repo,
preferences_service=prefs,
memory_cache=memory_cache,
disk_cache=disk_cache,
)
return svc, wikidata_repo
class TestGetArtistInfoBasic:
@pytest.mark.asyncio
async def test_cold_cache_skips_wikidata(self):
svc, wikidata_repo = _make_service()
result = await svc.get_artist_info_basic(ARTIST_MBID)
assert result.name == "Test Artist"
assert result.musicbrainz_id == ARTIST_MBID
assert result.description is None
assert result.image is None
wikidata_repo.get_wikidata_info.assert_not_awaited()
@pytest.mark.asyncio
async def test_cold_cache_sets_release_group_count(self):
svc, _ = _make_service()
result = await svc.get_artist_info_basic(ARTIST_MBID)
assert result.release_group_count == 1
@pytest.mark.asyncio
async def test_cached_artist_returned_directly(self):
cached = ArtistInfo(
name="Cached Artist",
musicbrainz_id=ARTIST_MBID,
description="Cached description",
image="https://example.com/img.jpg",
albums=[ReleaseItem(id="rg-cached", title="Cached Album", type="Album")],
)
svc, wikidata_repo = _make_service(cached_artist=cached)
result = await svc.get_artist_info_basic(ARTIST_MBID)
assert result.name == "Cached Artist"
assert result.description == "Cached description"
assert result.image == "https://example.com/img.jpg"
wikidata_repo.get_wikidata_info.assert_not_awaited()
@pytest.mark.asyncio
async def test_invalid_mbid_raises_value_error(self):
svc, _ = _make_service()
with pytest.raises(ValueError):
await svc.get_artist_info_basic("not-a-uuid")
@@ -416,30 +416,29 @@ class TestGetTopAlbumsSource:
assert result.albums[2].requested is False
@pytest.mark.asyncio
async def test_source_lastfm_resolves_release_mbids_to_release_groups(self):
async def test_source_lastfm_uses_raw_mbids_without_resolution(self):
lastfm_albums = [
LastFmAlbum(name="Album A", artist_name="Artist", mbid="release-mbid-a", playcount=100),
LastFmAlbum(name="Album B", artist_name="Artist", mbid="release-mbid-b", playcount=50),
]
svc, _, lastfm_repo, _ = _make_service()
lastfm_repo.get_artist_top_albums.return_value = lastfm_albums
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"rg-resolved-a"})
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"release-mbid-a"})
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
async def mock_resolve(rid):
return {"release-mbid-a": "rg-resolved-a", "release-mbid-b": "rg-resolved-b"}.get(rid)
svc._mb_repo.get_release_group_id_from_release = mock_resolve
svc._mb_repo.get_release_group_id_from_release = AsyncMock(
side_effect=AssertionError("Resolution should not be called")
)
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
assert result.albums[0].release_group_mbid == "rg-resolved-a"
assert result.albums[0].release_group_mbid == "release-mbid-a"
assert result.albums[0].in_library is True
assert result.albums[1].release_group_mbid == "rg-resolved-b"
assert result.albums[1].release_group_mbid == "release-mbid-b"
assert result.albums[1].in_library is False
@pytest.mark.asyncio
async def test_source_lastfm_keeps_original_mbid_when_resolution_fails(self):
async def test_source_lastfm_keeps_raw_mbid_directly(self):
lastfm_albums = [
LastFmAlbum(name="Album A", artist_name="Artist", mbid="already-rg-mbid", playcount=100),
]
@@ -448,8 +447,6 @@ class TestGetTopAlbumsSource:
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
svc._mb_repo.get_release_group_id_from_release = AsyncMock(return_value=None)
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
assert result.albums[0].release_group_mbid == "already-rg-mbid"
@@ -0,0 +1,63 @@
"""Tests for extract_tags deduplication in artist_utils."""
from services.artist_utils import extract_tags
def test_extract_tags_deduplicates():
mb_artist = {
"tags": [
{"name": "rock"},
{"name": "indie"},
{"name": "rock"},
{"name": "alternative"},
{"name": "indie"},
]
}
result = extract_tags(mb_artist)
assert result == ["rock", "indie", "alternative"]
def test_extract_tags_preserves_order():
mb_artist = {
"tags": [
{"name": "electronic"},
{"name": "ambient"},
{"name": "electronic"},
{"name": "downtempo"},
]
}
result = extract_tags(mb_artist)
assert result == ["electronic", "ambient", "downtempo"]
def test_extract_tags_respects_limit_after_dedup():
mb_artist = {
"tags": [
{"name": "a"},
{"name": "b"},
{"name": "a"},
{"name": "c"},
{"name": "d"},
]
}
result = extract_tags(mb_artist, limit=2)
assert result == ["a", "b"]
def test_extract_tags_empty():
assert extract_tags({}) == []
assert extract_tags({"tags": []}) == []
def test_extract_tags_skips_empty_names():
mb_artist = {
"tags": [
{"name": "rock"},
{"name": ""},
{"name": None},
{},
{"name": "rock"},
]
}
result = extract_tags(mb_artist)
assert result == ["rock"]
@@ -103,8 +103,8 @@ async def test_lock_released_after_exception():
@pytest.mark.asyncio
async def test_delay_holds_semaphore_slot():
"""Delay is applied inside the semaphore, blocking other artists from starting."""
async def test_delay_does_not_hold_semaphore_slot():
"""Delay is applied outside the semaphore, allowing other artists to start during sleep."""
svc = _make_service()
timestamps: list[float] = []
@@ -125,12 +125,13 @@ async def test_delay_holds_semaphore_slot():
)
assert len(timestamps) == 4
# With concurrency=2 and delay=0.15s inside semaphore, the 3rd artist
# cannot start until one of the first two finishes its delay.
# The gap between the 2nd and 3rd timestamps should be >= delay.
# With concurrency=2 and delay=0.15s OUTSIDE semaphore, the 3rd artist
# can start as soon as a semaphore slot frees (before the delay finishes).
# All four API calls should start quickly — the gap between 1st and 3rd
# should be small since the semaphore isn't held during sleep.
sorted_ts = sorted(timestamps)
gap = sorted_ts[2] - sorted_ts[0]
assert gap >= 0.1, f"Expected >=0.1s gap due to semaphore-held delay, got {gap:.3f}s"
assert gap < 0.15, f"Expected <0.15s gap since sleep is outside semaphore, got {gap:.3f}s"
@pytest.mark.asyncio
@@ -190,3 +191,32 @@ async def test_guard_survives_instance_recreation():
await task1
assert _ads_module._discovery_precache_running is False
@pytest.mark.asyncio
async def test_worker_timeout_fires_and_updates_progress():
"""A worker that exceeds the per-artist timeout is killed and progress is updated."""
svc = _make_service()
async def hang_forever(*args, **kwargs):
await asyncio.sleep(9999)
return MagicMock() # pragma: no cover
status = MagicMock()
status.is_cancelled = MagicMock(return_value=False)
status.update_progress = AsyncMock()
with (
patch.object(svc, "get_similar_artists", new_callable=AsyncMock, side_effect=hang_forever),
patch.object(svc, "get_top_songs", new_callable=AsyncMock, side_effect=hang_forever),
patch.object(svc, "get_top_albums", new_callable=AsyncMock, side_effect=hang_forever),
patch("services.artist_discovery_service._DISCOVERY_WORKER_TIMEOUT", 0.1),
):
result = await svc.precache_artist_discovery(
["mbid-a"], delay=0, status_service=status,
)
assert result == 0
assert status.update_progress.await_count >= 1
last_call_args = status.update_progress.call_args
assert "timed out" in str(last_call_args)
@@ -58,8 +58,8 @@ async def test_progress_updates_on_success():
)
assert status.update_progress.call_count == 2
status.update_progress.assert_any_call(1, current_item="Artist A")
status.update_progress.assert_any_call(2, current_item="Artist B")
status.update_progress.assert_any_call(1, current_item="Artist A", generation=0)
status.update_progress.assert_any_call(2, current_item="Artist B", generation=0)
@pytest.mark.asyncio
@@ -82,9 +82,9 @@ async def test_progress_updates_even_on_failure():
)
assert status.update_progress.call_count == 3
status.update_progress.assert_any_call(1, current_item="A")
status.update_progress.assert_any_call(2, current_item="B")
status.update_progress.assert_any_call(3, current_item="C")
status.update_progress.assert_any_call(1, current_item="A", generation=0)
status.update_progress.assert_any_call(2, current_item="B", generation=0)
status.update_progress.assert_any_call(3, current_item="C", generation=0)
@pytest.mark.asyncio
@@ -0,0 +1,158 @@
"""Tests for _refresh_library_flags in_lidarr/monitored/auto_download refresh."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from models.artist import ArtistInfo
from services.artist_service import ArtistService
@pytest.fixture
def mock_lidarr_repo():
repo = AsyncMock()
repo.is_configured = MagicMock(return_value=True)
repo.get_library_mbids.return_value = set()
repo.get_requested_mbids.return_value = set()
repo.get_artist_mbids.return_value = set()
repo.get_artist_details.return_value = None
return repo
@pytest.fixture
def artist_service(mock_lidarr_repo):
return ArtistService(
mb_repo=AsyncMock(),
lidarr_repo=mock_lidarr_repo,
wikidata_repo=AsyncMock(),
preferences_service=MagicMock(),
memory_cache=AsyncMock(),
disk_cache=AsyncMock(),
)
def _make_artist(mbid: str = "aaa-bbb", in_lidarr: bool = False,
monitored: bool = False, auto_download: bool = False) -> ArtistInfo:
return ArtistInfo(
name="Test Artist",
musicbrainz_id=mbid,
in_lidarr=in_lidarr,
monitored=monitored,
auto_download=auto_download,
)
class TestRefreshLibraryFlagsLidarrTransition:
"""When an artist transitions into artist_mbids, in_lidarr/monitored/auto_download should update."""
@pytest.mark.asyncio
async def test_transition_sets_in_lidarr_and_monitoring(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_mbids.return_value = {"aaa-bbb"}
mock_lidarr_repo.get_artist_details.return_value = {
"monitored": True, "monitor_new_items": "all",
}
artist = _make_artist(in_lidarr=False)
await artist_service._refresh_library_flags(artist)
assert artist.in_lidarr is True
assert artist.monitored is True
assert artist.auto_download is True
mock_lidarr_repo.get_artist_details.assert_awaited_once_with("aaa-bbb")
@pytest.mark.asyncio
async def test_transition_monitored_false_auto_download_none(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_mbids.return_value = {"aaa-bbb"}
mock_lidarr_repo.get_artist_details.return_value = {
"monitored": False, "monitor_new_items": "none",
}
artist = _make_artist(in_lidarr=False)
await artist_service._refresh_library_flags(artist)
assert artist.in_lidarr is True
assert artist.monitored is False
assert artist.auto_download is False
@pytest.mark.asyncio
async def test_transition_details_none_still_sets_in_lidarr(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_mbids.return_value = {"aaa-bbb"}
mock_lidarr_repo.get_artist_details.return_value = None
artist = _make_artist(in_lidarr=False)
await artist_service._refresh_library_flags(artist)
assert artist.in_lidarr is True
@pytest.mark.asyncio
async def test_transition_details_exception_graceful_degradation(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_mbids.return_value = {"aaa-bbb"}
mock_lidarr_repo.get_artist_details.side_effect = Exception("Lidarr down")
artist = _make_artist(in_lidarr=False)
await artist_service._refresh_library_flags(artist)
assert artist.in_lidarr is True
assert artist.monitored is False
assert artist.auto_download is False
@pytest.mark.asyncio
async def test_already_in_lidarr_refreshes_monitoring_flags(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_mbids.return_value = {"aaa-bbb"}
mock_lidarr_repo.get_artist_details.return_value = {
"monitored": False, "monitor_new_items": "none",
}
artist = _make_artist(in_lidarr=True, monitored=True, auto_download=True)
await artist_service._refresh_library_flags(artist)
assert artist.in_lidarr is True
assert artist.monitored is False
assert artist.auto_download is False
mock_lidarr_repo.get_artist_details.assert_awaited_once_with("aaa-bbb")
@pytest.mark.asyncio
async def test_removed_from_artist_mbids_preserves_lidarr_flags(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_artist_mbids.return_value = set()
artist = _make_artist(in_lidarr=True, monitored=True, auto_download=True)
await artist_service._refresh_library_flags(artist)
assert artist.in_library is False
assert artist.in_lidarr is True
assert artist.monitored is True
assert artist.auto_download is True
@pytest.mark.asyncio
async def test_not_configured_skips(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.is_configured.return_value = False
artist = _make_artist(in_lidarr=False)
await artist_service._refresh_library_flags(artist)
assert artist.in_lidarr is False
mock_lidarr_repo.get_artist_mbids.assert_not_awaited()
@pytest.mark.asyncio
async def test_release_in_library_flags_still_refreshed(self, artist_service, mock_lidarr_repo):
mock_lidarr_repo.get_library_mbids.return_value = {"album-1"}
mock_lidarr_repo.get_requested_mbids.return_value = {"album-2"}
mock_lidarr_repo.get_artist_mbids.return_value = set()
from models.artist import ReleaseItem
artist = _make_artist()
artist = ArtistInfo(
name="Test", musicbrainz_id="aaa-bbb",
albums=[
ReleaseItem(id="album-1", title="A"),
ReleaseItem(id="album-2", title="B"),
ReleaseItem(id="album-3", title="C"),
],
)
await artist_service._refresh_library_flags(artist)
assert artist.albums[0].in_library is True
assert artist.albums[0].requested is False
assert artist.albums[1].in_library is False
assert artist.albums[1].requested is True
assert artist.albums[2].in_library is False
assert artist.albums[2].requested is False
@@ -0,0 +1,98 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from repositories.lastfm_models import LastFmAlbum
from services.artist_discovery_service import ArtistDiscoveryService
ARTIST_MBID = "f4a31f0a-51dd-4fa7-986d-3095c40c5ed9"
RELEASE_MBID_1 = "aaaaaaaa-0000-0000-0000-000000000001"
RELEASE_MBID_2 = "aaaaaaaa-0000-0000-0000-000000000002"
def _make_lastfm_albums() -> list[LastFmAlbum]:
return [
LastFmAlbum(name="Album A", artist_name="Test Artist", mbid=RELEASE_MBID_1, playcount=5000),
LastFmAlbum(name="Album B", artist_name="Test Artist", mbid=RELEASE_MBID_2, playcount=3000),
LastFmAlbum(name="Album C (no mbid)", artist_name="Test Artist", mbid="", playcount=1000),
]
def _make_service() -> tuple[ArtistDiscoveryService, AsyncMock]:
lb_repo = MagicMock()
lb_repo.is_configured.return_value = False
lastfm_repo = AsyncMock()
lastfm_repo.get_artist_top_albums = AsyncMock(return_value=_make_lastfm_albums())
prefs = MagicMock()
prefs.is_lastfm_enabled.return_value = True
library_db = AsyncMock()
library_db.get_all_artist_mbids = AsyncMock(return_value=set())
memory_cache = AsyncMock()
memory_cache.get = AsyncMock(return_value=None)
memory_cache.set = AsyncMock()
mb_repo = AsyncMock()
mb_repo.get_release_group_id_from_release = AsyncMock(
side_effect=AssertionError("MusicBrainz resolution should NOT be called for Last.fm top-albums")
)
lidarr_repo = AsyncMock()
lidarr_repo.get_library_mbids = AsyncMock(return_value={RELEASE_MBID_1})
lidarr_repo.get_requested_mbids = AsyncMock(return_value={RELEASE_MBID_2})
svc = ArtistDiscoveryService(
listenbrainz_repo=lb_repo,
musicbrainz_repo=mb_repo,
library_db=library_db,
lidarr_repo=lidarr_repo,
memory_cache=memory_cache,
lastfm_repo=lastfm_repo,
preferences_service=prefs,
)
return svc, mb_repo
class TestLastFmTopAlbumsNoResolution:
@pytest.mark.asyncio
async def test_no_musicbrainz_resolution_called(self):
svc, mb_repo = _make_service()
result = await svc.get_top_albums(ARTIST_MBID, count=10, source="lastfm")
assert len(result.albums) == 3
mb_repo.get_release_group_id_from_release.assert_not_awaited()
@pytest.mark.asyncio
async def test_uses_raw_lastfm_mbid(self):
svc, _ = _make_service()
result = await svc.get_top_albums(ARTIST_MBID, count=10, source="lastfm")
assert result.albums[0].release_group_mbid == RELEASE_MBID_1
assert result.albums[1].release_group_mbid == RELEASE_MBID_2
assert result.albums[2].release_group_mbid is None
@pytest.mark.asyncio
async def test_library_flags_use_raw_mbid(self):
svc, _ = _make_service()
result = await svc.get_top_albums(ARTIST_MBID, count=10, source="lastfm")
assert result.albums[0].in_library is True
assert result.albums[0].requested is False
assert result.albums[1].in_library is False
assert result.albums[1].requested is True
assert result.albums[2].in_library is False
assert result.albums[2].requested is False
@pytest.mark.asyncio
async def test_source_is_lastfm(self):
svc, _ = _make_service()
result = await svc.get_top_albums(ARTIST_MBID, count=10, source="lastfm")
assert result.source == "lastfm"
@@ -209,7 +209,8 @@ class TestSyncSettingsRoundTrip:
("sync_max_timeout_hours", 8),
("audiodb_prewarm_concurrency", 4),
("audiodb_prewarm_delay", 0.3),
("artist_discovery_precache_concurrency", 3),
("artist_discovery_precache_concurrency", 5),
("artist_discovery_precache_delay", 0.2),
])
def test_defaults_match(self, field: str, default_val) -> None:
backend = AdvancedSettings()
+2 -2
View File
@@ -126,7 +126,7 @@ class TestAudioDBParallel:
status = _make_status_service()
await phase.precache_audiodb_data([], [], status)
status.skip_phase.assert_called_once_with('audiodb_prewarm')
status.skip_phase.assert_called_once_with('audiodb_prewarm', generation=0)
assert True
@pytest.mark.asyncio
@@ -149,5 +149,5 @@ class TestAudioDBParallel:
await phase.precache_audiodb_data(artists, [], status)
status.skip_phase.assert_called_once_with('audiodb_prewarm')
status.skip_phase.assert_called_once_with('audiodb_prewarm', generation=0)
assert True
@@ -0,0 +1,84 @@
"""Tests that queue processor and on_queue_import invalidate disk cache for artist."""
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
import pytest
from unittest.mock import AsyncMock, MagicMock
from core.dependencies.service_providers import make_on_queue_import, make_processor
from infrastructure.persistence.request_history import RequestHistoryRecord
def _make_record(artist_mbid: str | None = "artist-aaa") -> RequestHistoryRecord:
return RequestHistoryRecord(
musicbrainz_id="album-111",
artist_name="Test",
album_title="Album",
requested_at="2025-01-01",
status="pending",
artist_mbid=artist_mbid,
monitor_artist=True,
auto_download_artist=False,
)
class TestOnQueueImportDiskInvalidation:
"""on_queue_import should call disk_cache.delete_artist when artist_mbid is present."""
@pytest.mark.asyncio
async def test_disk_cache_deleted_for_artist(self):
disk_cache = AsyncMock()
memory_cache = AsyncMock()
memory_cache.delete.return_value = None
memory_cache.clear_prefix.return_value = 0
library_db = AsyncMock()
record = _make_record(artist_mbid="artist-aaa")
on_queue_import = make_on_queue_import(memory_cache, disk_cache, library_db)
await on_queue_import(record)
disk_cache.delete_artist.assert_awaited_once_with("artist-aaa")
@pytest.mark.asyncio
async def test_disk_cache_not_called_without_artist_mbid(self):
disk_cache = AsyncMock()
memory_cache = AsyncMock()
memory_cache.delete.return_value = None
memory_cache.clear_prefix.return_value = 0
library_db = AsyncMock()
record = _make_record(artist_mbid=None)
on_queue_import = make_on_queue_import(memory_cache, disk_cache, library_db)
await on_queue_import(record)
disk_cache.delete_artist.assert_not_awaited()
class TestProcessorDiskInvalidation:
"""processor should call disk_cache.delete_artist after deferred monitoring."""
@pytest.mark.asyncio
async def test_disk_cache_deleted_after_artist_monitoring(self):
disk_cache = AsyncMock()
memory_cache = AsyncMock()
memory_cache.delete.return_value = None
lidarr_repo = AsyncMock()
lidarr_repo.add_album.return_value = {
"payload": {"monitored": True, "artist": {"foreignArtistId": "artist-aaa"}},
"monitored": True,
}
lidarr_repo.update_artist_monitoring.return_value = {}
request_history = MagicMock()
record = _make_record()
request_history.async_get_record = AsyncMock(return_value=record)
cover_repo = AsyncMock()
processor = make_processor(lidarr_repo, memory_cache, disk_cache, cover_repo, request_history)
await processor("album-111")
disk_cache.delete_artist.assert_awaited_once_with("artist-aaa")
+219
View File
@@ -0,0 +1,219 @@
"""Tests for MUS-19: sync generation counter, false-failed status, cancel, progress clamp."""
import asyncio
import os
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from services.cache_status_service import CacheStatusService, CacheSyncProgress
def _make_status_service() -> CacheStatusService:
store = AsyncMock()
store.save_sync_state = AsyncMock()
svc = CacheStatusService(store)
svc._sse_subscribers = []
return svc
class TestGenerationCounter:
"""Generation counter rejects stale writes from old syncs."""
@pytest.mark.asyncio
async def test_start_sync_returns_generation(self):
svc = _make_status_service()
gen1 = await svc.start_sync('artists', 10)
assert gen1 >= 1
gen2 = await svc.start_sync('artists', 5)
assert gen2 == gen1 + 1
@pytest.mark.asyncio
async def test_stale_update_progress_rejected(self):
svc = _make_status_service()
gen1 = await svc.start_sync('artists', 10)
gen2 = await svc.start_sync('artists', 5)
await svc.update_progress(3, 'old item', generation=gen1)
progress = svc.get_progress()
assert progress.processed_items == 0, "Stale generation write should be rejected"
await svc.update_progress(2, 'new item', generation=gen2)
progress = svc.get_progress()
assert progress.processed_items == 2
@pytest.mark.asyncio
async def test_stale_update_phase_rejected(self):
svc = _make_status_service()
gen1 = await svc.start_sync('artists', 10)
gen2 = await svc.start_sync('albums', 5)
await svc.update_phase('audiodb_prewarm', 100, generation=gen1)
progress = svc.get_progress()
assert progress.phase == 'albums', "Stale generation should not change phase"
@pytest.mark.asyncio
async def test_stale_complete_sync_rejected(self):
svc = _make_status_service()
gen1 = await svc.start_sync('artists', 10)
_gen2 = await svc.start_sync('artists', 5)
await svc.complete_sync(generation=gen1)
progress = svc.get_progress()
assert progress.is_syncing is True, "Stale complete_sync should not stop current sync"
@pytest.mark.asyncio
async def test_stale_skip_phase_rejected(self):
svc = _make_status_service()
gen1 = await svc.start_sync('artists', 10)
gen2 = await svc.start_sync('albums', 5)
await svc.skip_phase('albums', generation=gen1)
progress = svc.get_progress()
assert progress.phase == 'albums', "Stale skip_phase should be rejected"
@pytest.mark.asyncio
async def test_stale_persist_progress_rejected(self):
svc = _make_status_service()
gen1 = await svc.start_sync('artists', 10)
_gen2 = await svc.start_sync('artists', 5)
svc._sync_state_store.save_sync_state.reset_mock()
await svc.persist_progress(generation=gen1)
svc._sync_state_store.save_sync_state.assert_not_called()
@pytest.mark.asyncio
async def test_generation_zero_bypasses_guard(self):
"""generation=0 (default) always passes through, for backward compatibility."""
svc = _make_status_service()
_gen = await svc.start_sync('artists', 10)
await svc.update_progress(5, 'item', generation=0)
progress = svc.get_progress()
assert progress.processed_items == 5
class TestProgressClamp:
"""progress_percent is clamped to 100."""
@pytest.mark.asyncio
async def test_percent_clamped_to_100(self):
svc = _make_status_service()
await svc.start_sync('artists', 5)
await svc.update_progress(20, 'overflow')
progress = svc.get_progress()
assert progress.progress_percent <= 100
class TestSkippedAutoSync:
"""Skipped auto-sync must not flip last_sync_success to False."""
@pytest.mark.asyncio
async def test_skipped_sync_does_not_update_status(self):
from core.tasks import sync_library_periodically
from api.v1.schemas.library import SyncLibraryResponse
mock_lib = AsyncMock()
mock_lib._lidarr_repo = MagicMock()
mock_lib._lidarr_repo.is_configured.return_value = True
mock_lib.sync_library.return_value = SyncLibraryResponse(
status="skipped", artists=0, albums=0
)
mock_prefs = MagicMock()
lidarr_settings = MagicMock()
lidarr_settings.sync_frequency = "5min"
mock_prefs.get_lidarr_settings.return_value = lidarr_settings
call_count = 0
original_sleep = asyncio.sleep
async def fake_sleep(duration):
nonlocal call_count
call_count += 1
if call_count >= 2:
raise asyncio.CancelledError()
await original_sleep(0)
with patch('asyncio.sleep', side_effect=fake_sleep):
try:
await sync_library_periodically(mock_lib, mock_prefs)
except asyncio.CancelledError:
pass
mock_prefs.save_lidarr_settings.assert_not_called()
class TestCancelSync:
"""Cancel endpoint and cancellation behavior."""
@pytest.mark.asyncio
async def test_cancel_always_sets_event(self):
svc = _make_status_service()
assert not svc.is_cancelled()
await svc.cancel_current_sync()
assert svc.is_cancelled()
@pytest.mark.asyncio
async def test_cancel_works_when_not_syncing(self):
"""Cancel should work even when is_syncing is False (post-completion AudioDB)."""
svc = _make_status_service()
await svc.cancel_current_sync()
assert svc.is_cancelled()
class TestCancelRoute:
"""Cancel sync API endpoint."""
@pytest.mark.skipif(
not os.access('/app', os.W_OK),
reason="Route tests require /app to be writable (Docker environment)",
)
def test_cancel_endpoint_calls_service_and_registry(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.cache_status import router
from core.dependencies import get_cache_status_service
mock_svc = MagicMock()
mock_svc.cancel_current_sync = AsyncMock()
mock_svc.wait_for_completion = AsyncMock()
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_cache_status_service] = lambda: mock_svc
with patch("core.task_registry.TaskRegistry") as MockRegistry:
mock_registry_instance = MagicMock()
MockRegistry.get_instance.return_value = mock_registry_instance
client = TestClient(app)
resp = client.post("/cache/sync/cancel")
assert resp.status_code == 200
assert resp.json() == {"status": "cancelled"}
mock_svc.cancel_current_sync.assert_awaited_once()
mock_registry_instance.cancel.assert_called_once_with("precache-library")
mock_svc.wait_for_completion.assert_awaited_once()
class TestRestoreAudioDBPhase:
"""restore_from_persistence handles audiodb_prewarm phase."""
@pytest.mark.asyncio
async def test_audiodb_prewarm_phase_restores(self):
svc = _make_status_service()
svc._sync_state_store.get_sync_state = AsyncMock(return_value={
'status': 'running',
'phase': 'audiodb_prewarm',
'total_artists': 100,
'processed_artists': 100,
'total_albums': 50,
'processed_albums': 50,
'started_at': 1000,
})
await svc.restore_from_persistence()
progress = svc.get_progress()
assert progress.is_syncing is True
assert progress.phase == 'audiodb_prewarm'
assert progress.total_items == 0
+28 -5
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { Check } from 'lucide-svelte';
import { Check, RefreshCw } from 'lucide-svelte';
import type { ArtistInfo } from '$lib/types';
import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors';
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
import { imageSettingsStore } from '$lib/stores/imageSettings';
import { getValidPendingMonitor, monitoredArtistsStore } from '$lib/stores/monitoredArtists';
import ArtistLinks from './ArtistLinks.svelte';
import ArtistMonitoringToggle from './ArtistMonitoringToggle.svelte';
import BackButton from './BackButton.svelte';
@@ -15,14 +16,21 @@
interface Props {
artist: ArtistInfo;
showBackButton?: boolean;
refreshing?: boolean;
onrefresh?: () => void;
}
let { artist, showBackButton = false }: Props = $props();
let { artist, showBackButton = false, refreshing = false, onrefresh }: Props = $props();
let heroGradient = $state(DEFAULT_GRADIENT);
let heroImageLoaded = $state(false);
let avatarRemoteError = $state(false);
let pendingMonitor = $derived.by(() =>
getValidPendingMonitor($monitoredArtistsStore, artist.musicbrainz_id)
);
let showMonitoring = $derived(artist.in_lidarr || !!pendingMonitor);
let useRemoteAvatar = $derived(artist.thumb_url && $imageSettingsStore.directRemoteImagesEnabled);
let resolvedRemoteAvatar = $derived(
artist.thumb_url ? appendAudioDBSizeSuffix(artist.thumb_url, 'hero') : null
@@ -76,6 +84,16 @@
/>
<div class="relative z-10 px-4 sm:px-8 lg:px-12 pt-6 pb-8 sm:pt-8 sm:pb-12">
{#if onrefresh && artist.in_lidarr}
<button
class="absolute top-3 right-3 btn btn-sm btn-ghost btn-circle z-20"
onclick={onrefresh}
disabled={refreshing}
title="Refresh artist info"
>
<RefreshCw class="h-5 w-5 {refreshing ? 'animate-spin' : ''}" />
</button>
{/if}
<div class="max-w-7xl mx-auto">
{#if showBackButton}
<div class="mb-4">
@@ -167,12 +185,17 @@
<ArtistLinks links={validLinks} />
{/if}
{#if artist.in_lidarr}
{#if showMonitoring}
<div class="mt-3">
<ArtistMonitoringToggle
artistMbid={artist.musicbrainz_id}
monitored={artist.monitored ?? false}
autoDownload={artist.auto_download ?? false}
monitored={pendingMonitor && !artist.in_lidarr
? pendingMonitor.monitored
: (artist.monitored ?? false)}
autoDownload={pendingMonitor && !artist.in_lidarr
? pendingMonitor.autoDownload
: (artist.auto_download ?? false)}
disabled={!!pendingMonitor && !artist.in_lidarr}
on:change={(e) => {
artist.monitored = e.detail.monitored;
artist.auto_download = e.detail.autoDownload;
@@ -122,7 +122,7 @@
<span class="text-xs font-semibold uppercase tracking-widest text-base-content/70 mr-1">
{group.label}
</span>
{#each group.links as link (link.type)}
{#each group.links as link (link.label)}
<a
href={link.url}
target="_blank"
@@ -98,7 +98,7 @@
</div>
{:else}
<div class="space-y-1">
{#each albums as album (album.title + album.artist_name)}
{#each albums as album, i (album.release_group_mbid || `album-${i}`)}
{#if album.release_group_mbid}
<a
href={albumHref(album.release_group_mbid)}
@@ -186,7 +186,7 @@
</div>
{:else}
<div class="space-y-1">
{#each songs as song, i (song.title + song.artist_name)}
{#each songs as song, i (song.recording_mbid || `song-${i}`)}
<TrackRow
{song}
position={i + 1}
@@ -126,12 +126,18 @@
{/if}
<div class="card-actions justify-end gap-2">
<button class="btn btn-ghost" onclick={syncNow} disabled={syncing}>
{#if syncing}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Sync Now
</button>
{#if syncStatus.isActive}
<button class="btn btn-error" onclick={() => syncStatus.cancelSync()}>
Stop Sync
</button>
{:else}
<button class="btn btn-ghost" onclick={syncNow} disabled={syncing}>
{#if syncing}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Sync Now
</button>
{/if}
<button class="btn btn-primary" onclick={save} disabled={form.saving}>
{#if form.saving}
<span class="loading loading-spinner loading-sm"></span>
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getValidPendingMonitor,
monitoredArtistsStore,
type PendingMonitor
} from './monitoredArtists';
describe('getValidPendingMonitor', () => {
it('returns a fresh pending monitor entry', () => {
expect.assertions(1);
const now = Date.now();
const entry: PendingMonitor = {
monitored: true,
autoDownload: true,
timestamp: now - 1_000
};
const entries = new Map([['artist-mbid', entry]]);
expect(getValidPendingMonitor(entries, 'ARTIST-MBID', now)).toEqual(entry);
});
it('returns undefined for an expired pending monitor entry', () => {
expect.assertions(1);
const now = Date.now();
const entries = new Map([
[
'artist-mbid',
{
monitored: true,
autoDownload: false,
timestamp: now - (10 * 60 * 1000 + 1)
}
]
]);
expect(getValidPendingMonitor(entries, 'artist-mbid', now)).toBeUndefined();
});
});
describe('monitoredArtistsStore expiry', () => {
const artistMbid = 'artist-expiring';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(async () => {
monitoredArtistsStore.removePendingMonitor(artistMbid);
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
});
it('removes expired pending monitors from the live store after TTL elapses', async () => {
expect.assertions(3);
let latestEntries = new Map<string, PendingMonitor>();
const unsubscribe = monitoredArtistsStore.subscribe((entries) => {
latestEntries = entries;
});
monitoredArtistsStore.addPendingMonitor(artistMbid, true);
expect(monitoredArtistsStore.getPendingMonitor(artistMbid)).toEqual(
expect.objectContaining({ monitored: true, autoDownload: true })
);
await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);
expect(latestEntries.has(artistMbid)).toBe(false);
expect(monitoredArtistsStore.getPendingMonitor(artistMbid)).toBeUndefined();
unsubscribe();
});
});
+125
View File
@@ -0,0 +1,125 @@
import { writable, get } from 'svelte/store';
export interface PendingMonitor {
monitored: boolean;
autoDownload: boolean;
timestamp: number;
}
const STORAGE_KEY = 'musicseerr_pending_artist_monitors';
const MAX_AGE_MS = 10 * 60 * 1000;
export function getValidPendingMonitor(
entries: Map<string, PendingMonitor>,
artistMbid: string | undefined | null,
now = Date.now()
): PendingMonitor | undefined {
if (!artistMbid) return undefined;
const entry = entries.get(artistMbid.toLowerCase());
if (!entry) return undefined;
if (now - entry.timestamp >= MAX_AGE_MS) return undefined;
return entry;
}
function pruneExpiredPendingMonitors(
entries: Map<string, PendingMonitor>,
now = Date.now()
): Map<string, PendingMonitor> {
return new Map([...entries.entries()].filter(([, entry]) => now - entry.timestamp < MAX_AGE_MS));
}
function loadFromStorage(): Map<string, PendingMonitor> {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return new Map();
const entries: [string, PendingMonitor][] = JSON.parse(raw);
return pruneExpiredPendingMonitors(new Map(entries));
} catch {
return new Map();
}
}
function persist(map: Map<string, PendingMonitor>): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...map.entries()]));
} catch {
/* storage full or unavailable */
}
}
function createMonitoredArtistsStore() {
const initialEntries = loadFromStorage();
const { subscribe, update } = writable<Map<string, PendingMonitor>>(initialEntries);
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;
function clearExpiryTimeout(): void {
if (expiryTimeout) {
clearTimeout(expiryTimeout);
expiryTimeout = null;
}
}
function scheduleExpiry(entries: Map<string, PendingMonitor>): void {
clearExpiryTimeout();
let nextExpiryAt: number | null = null;
for (const entry of entries.values()) {
const expiresAt = entry.timestamp + MAX_AGE_MS;
if (nextExpiryAt === null || expiresAt < nextExpiryAt) {
nextExpiryAt = expiresAt;
}
}
if (nextExpiryAt === null) return;
const delay = Math.max(nextExpiryAt - Date.now(), 0);
expiryTimeout = setTimeout(() => {
update((map) => {
const next = pruneExpiredPendingMonitors(map);
if (next.size !== map.size) {
persist(next);
}
scheduleExpiry(next);
return next.size === map.size ? map : next;
});
}, delay);
}
function addPendingMonitor(artistMbid: string, autoDownload: boolean): void {
update((map) => {
const next = new Map(map);
next.set(artistMbid.toLowerCase(), {
monitored: true,
autoDownload,
timestamp: Date.now()
});
persist(next);
scheduleExpiry(next);
return next;
});
}
function removePendingMonitor(artistMbid: string): void {
update((map) => {
const key = artistMbid.toLowerCase();
if (!map.has(key)) return map;
const next = new Map(map);
next.delete(key);
persist(next);
scheduleExpiry(next);
return next;
});
}
function getPendingMonitor(artistMbid: string | undefined | null): PendingMonitor | undefined {
return getValidPendingMonitor(get({ subscribe }), artistMbid);
}
scheduleExpiry(initialEntries);
return {
subscribe,
addPendingMonitor,
removePendingMonitor,
getPendingMonitor
};
}
export const monitoredArtistsStore = createMonitoredArtistsStore();
+12 -4
View File
@@ -58,9 +58,6 @@ function createSyncStatusStore() {
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
// TODO: do we need this?
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let statusVersion = 0;
let connected = false;
function clearAllTimers(): void {
@@ -79,7 +76,6 @@ function createSyncStatusStore() {
}
function applyStatus(newStatus: SyncStatus): void {
statusVersion++;
const wasSyncing = status.is_syncing;
status = newStatus;
@@ -297,6 +293,10 @@ function createSyncStatusStore() {
isDismissed = true;
},
undismiss(): void {
isDismissed = false;
},
minimize(): void {
isMinimized = true;
},
@@ -307,6 +307,14 @@ function createSyncStatusStore() {
checkStatus(): void {
void fetchStatus();
},
async cancelSync(): Promise<void> {
try {
await api.global.post('/api/v1/cache/sync/cancel');
} catch {
// ignore errors, sync may already be stopped
}
}
};
}
@@ -20,7 +20,7 @@ export interface EventHandlerDeps {
setRemovedArtistName: (v: string) => void;
setToast: (msg: string, type: 'success' | 'error' | 'info' | 'warning') => void;
setShowToast: (v: boolean) => void;
onRequestSuccess?: () => void;
onRequestSuccess?: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void;
}
export function createEventHandlers(deps: EventHandlerDeps) {
@@ -68,7 +68,7 @@ export function createEventHandlers(deps: EventHandlerDeps) {
deps.albumBasicCacheSet(current, deps.getAlbumId());
deps.setToast('Added to Library', 'success');
deps.setShowToast(true);
deps.onRequestSuccess?.();
deps.onRequestSuccess?.(opts);
}
} finally {
deps.setRequesting(false);
@@ -18,6 +18,7 @@ import type {
LastFmAlbumEnrichment
} from '$lib/types';
import { libraryStore } from '$lib/stores/library';
import { monitoredArtistsStore } from '$lib/stores/monitoredArtists';
import { integrationStore } from '$lib/stores/integration';
import { isAbortError } from '$lib/utils/errorHandling';
import { extractServiceStatus } from '$lib/utils/serviceStatus';
@@ -31,6 +32,7 @@ import {
albumSourceMatchCache
} from '$lib/utils/albumDetailCache';
import { hydrateDetailCacheEntry } from '$lib/utils/detailCacheHydration';
import { artistBasicCache } from '$lib/utils/artistDetailCache';
import { compareDiscTrack, getDiscTrackKey } from '$lib/player/queueHelpers';
import type { QueueItem } from '$lib/player/types';
import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback';
@@ -533,10 +535,14 @@ export function createAlbumPageState(albumIdGetter: () => string) {
toastType = type;
},
setShowToast: (v) => (showToast = v),
onRequestSuccess: () => {
onRequestSuccess: (opts) => {
albumSourceMatchCache.remove(albumIdGetter());
const aid = album?.artist_id;
if (aid && abortController) void fetchArtistMonitoringState(aid, abortController.signal);
if (opts?.monitorArtist && aid) {
monitoredArtistsStore.addPendingMonitor(aid, opts.autoDownloadArtist ?? false);
artistBasicCache.remove(aid);
}
}
});
+42 -8
View File
@@ -31,6 +31,7 @@
import { getArtistDiscoveryCache, setArtistDiscoveryCache } from '$lib/stores/discoveryCache';
import { integrationStore } from '$lib/stores/integration';
import { musicSourceStore, type MusicSource } from '$lib/stores/musicSource';
import { monitoredArtistsStore } from '$lib/stores/monitoredArtists';
import {
artistBasicCache,
artistExtendedCache,
@@ -50,6 +51,7 @@
let artist: ArtistInfo | null = $state(null);
let loadingBasic = $state(true);
let loadingExtended = $state(true);
let refreshingArtist = $state(false);
let error: string | null = $state(null);
let showToast = $state(false);
let toastMessage = 'Added to Library';
@@ -190,9 +192,16 @@
}
async function fetchArtist(force = false) {
const { refreshBasic, refreshExtended, refreshLastfm } = force
const hasPendingMonitor = !!monitoredArtistsStore.getPendingMonitor(data.artistId);
const {
refreshBasic: cacheRefreshBasic,
refreshExtended,
refreshLastfm
} = force
? { refreshBasic: true, refreshExtended: true, refreshLastfm: true }
: hydrateFromCache(data.artistId);
const staleLibraryFlags = artist && artist.in_library && !artist.in_lidarr;
const refreshBasic = cacheRefreshBasic || hasPendingMonitor || !!staleLibraryFlags;
if (!artist || refreshBasic) loadingBasic = true;
if (!artist || refreshExtended) loadingExtended = true;
@@ -217,21 +226,29 @@
void fetchDiscoveryData(musicSourceStore.getPageSource('artist'));
});
const forceBasic = force || !!staleLibraryFlags || hasPendingMonitor;
if (refreshBasic || !artist) {
await Promise.all([fetchBasicInfo(force), sourceLoadPromise]);
await Promise.all([fetchBasicInfo(forceBasic), sourceLoadPromise]);
} else {
await sourceLoadPromise;
}
if (artist) {
const secondaryPromises: Promise<void>[] = [];
if (refreshExtended) {
void fetchExtendedInfo(force, artist);
secondaryPromises.push(fetchExtendedInfo(force, artist));
}
if (refreshLastfm) {
void fetchLastFmEnrichment();
secondaryPromises.push(fetchLastFmEnrichment());
}
// Start background release loading after all other data has settled
if (hasMoreReleases) {
void fetchMoreReleases();
Promise.allSettled(secondaryPromises).then(() => {
if (abortController && !abortController.signal.aborted) {
void fetchMoreReleases();
}
});
}
}
}
@@ -254,6 +271,9 @@
artist = sortedResult.artistInfo;
applyArtistReleasePaginationState(sortedResult.pagination);
artistBasicCache.set(artist, data.artistId);
if (artistData.in_lidarr) {
monitoredArtistsStore.removePendingMonitor(data.artistId);
}
}
} catch (e) {
if (isAbortError(e)) {
@@ -492,6 +512,15 @@
}
}
async function handleRefreshClick() {
refreshingArtist = true;
try {
await fetchArtist(true);
} finally {
refreshingArtist = false;
}
}
onMount(() => {
if (browser) {
const handleRefresh = () => fetchArtist(true);
@@ -591,7 +620,12 @@
<div class="xl:col-start-2 xl:row-start-1 space-y-4 sm:space-y-6 lg:space-y-8">
<section id="section-overview" class="space-y-4 scroll-mt-24">
<ArtistHero {artist} showBackButton />
<ArtistHero
{artist}
showBackButton
refreshing={refreshingArtist}
onrefresh={handleRefreshClick}
/>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 justify-center sm:justify-start">
{#if artist.country}
@@ -617,7 +651,7 @@
{#if artist.tags.length > 0}
<div class="flex flex-wrap gap-2 justify-center sm:justify-start -mt-2">
{#each artist.tags.slice(0, 10) as tag (tag)}
{#each [...new Set(artist.tags)].slice(0, 10) as tag (tag)}
<a
href="/genre?name={encodeURIComponent(tag)}"
class="badge badge-lg cursor-pointer hover:opacity-80 transition-opacity"
@@ -692,7 +726,7 @@
<span class="loading loading-spinner loading-md" style="color: {colors.accent};"></span>
<div class="flex flex-col items-start">
<span class="font-semibold text-base" style="color: {colors.accent};"
>Loading all releases...</span
>Loading releases...</span
>
<span class="text-sm text-base-content/70"
>Loaded {loadedReleaseCount} of {totalReleaseCount} releases</span
+110 -24
View File
@@ -13,7 +13,16 @@
import { API } from '$lib/constants';
import { isAbortError } from '$lib/utils/errorHandling';
import type { Artist, Album } from '$lib/types';
import { CircleX, X, RefreshCw, ChevronRight, Search, Loader2, Settings2 } from 'lucide-svelte';
import {
CircleX,
X,
RefreshCw,
ChevronRight,
Search,
Loader2,
Settings2,
Eye
} from 'lucide-svelte';
const CIRCUIT_BREAKER_CODE = 'CIRCUIT_BREAKER_OPEN';
@@ -316,21 +325,62 @@
</p>
{/if}
</div>
<button
class="btn btn-sm btn-primary gap-1"
onclick={syncLibrary}
disabled={syncing || syncStatus.isActive}
>
{#if syncing || syncStatus.isActive}
<Loader2 class="h-4 w-4 animate-spin" />
<span class="hidden sm:inline">Syncing...</span>
<div class="flex gap-2">
{#if syncStatus.isActive}
<button
class="btn btn-sm btn-error gap-1"
onclick={() => syncStatus.cancelSync()}
aria-label="Stop Sync"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /></svg
>
<span class="hidden sm:inline">Stop Sync</span>
</button>
{#if syncStatus.isDismissed}
<button
class="btn btn-sm btn-ghost gap-1"
onclick={() => syncStatus.undismiss()}
aria-label="Show sync progress"
>
<Eye class="h-4 w-4" />
<span class="hidden sm:inline">Show Progress</span>
</button>
{/if}
{:else}
<RefreshCw class="h-4 w-4" />
<span class="hidden sm:inline">Sync Library</span>
<button class="btn btn-sm btn-primary gap-1" onclick={syncLibrary} disabled={syncing}>
{#if syncing}
<Loader2 class="h-4 w-4 animate-spin" />
<span class="hidden sm:inline">Syncing...</span>
{:else}
<RefreshCw class="h-4 w-4" />
<span class="hidden sm:inline">Sync Library</span>
{/if}
</button>
{/if}
</button>
</div>
</div>
{#if syncStatus.isActive}
<div class="w-full mt-2">
<div class="flex items-center gap-2 text-sm text-base-content/70 mb-1">
<span>{syncStatus.phaseLabel}</span>
{#if syncStatus.totalItems > 0}
<span>{syncStatus.processedItems}/{syncStatus.totalItems}</span>
{/if}
</div>
<progress class="progress progress-primary w-full" value={syncStatus.progress} max="100"
></progress>
</div>
{/if}
{#if !isSearching}
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">Recently Added</h2>
@@ -510,18 +560,54 @@
<p class="text-base-content/70 mb-4">
Your Lidarr library is empty or hasn't been synced yet.
</p>
<button
class="btn btn-primary gap-2"
onclick={syncLibrary}
disabled={syncing || syncStatus.isActive}
>
{#if syncing || syncStatus.isActive}
<Loader2 class="h-4 w-4 animate-spin" />
Syncing...
{:else}
Sync Now
{/if}
</button>
{#if syncStatus.isActive}
<div class="flex flex-col items-center gap-3 w-full max-w-md">
<div class="flex gap-2">
<button class="btn btn-error gap-2" onclick={() => syncStatus.cancelSync()}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /></svg
>
Stop Sync
</button>
{#if syncStatus.isDismissed}
<button
class="btn btn-ghost gap-2"
onclick={() => syncStatus.undismiss()}
aria-label="Show sync progress"
>
<Eye class="h-4 w-4" />
Show Progress
</button>
{/if}
</div>
<div class="w-full">
<div class="flex items-center justify-center gap-2 text-sm text-base-content/70 mb-1">
<span>{syncStatus.phaseLabel}</span>
{#if syncStatus.totalItems > 0}
<span>{syncStatus.processedItems}/{syncStatus.totalItems}</span>
{/if}
</div>
<progress class="progress progress-primary w-full" value={syncStatus.progress} max="100"
></progress>
</div>
</div>
{:else}
<button class="btn btn-primary gap-2" onclick={syncLibrary} disabled={syncing}>
{#if syncing}
<Loader2 class="h-4 w-4 animate-spin" />
Syncing...
{:else}
Sync Now
{/if}
</button>
{/if}
</div>
{/if}
</div>