e84f2d6127
* feat: robust library sync with adaptive watchdog, resume-on-failure & parallel pre-warming * update copy
751 lines
33 KiB
Python
751 lines
33 KiB
Python
import logging
|
|
import asyncio
|
|
import time
|
|
from typing import Any, TYPE_CHECKING
|
|
from repositories.protocols import LidarrRepositoryProtocol, CoverArtRepositoryProtocol
|
|
from api.v1.schemas.library import (
|
|
LibraryAlbum,
|
|
LibraryArtist,
|
|
LibraryGroupedArtist,
|
|
LibraryStatsResponse,
|
|
SyncLibraryResponse,
|
|
AlbumRemovePreviewResponse,
|
|
AlbumRemoveResponse,
|
|
ResolvedTrack,
|
|
TrackResolveResponse,
|
|
TrackResolveRequest,
|
|
)
|
|
from infrastructure.persistence import LibraryDB, SyncStateStore, GenreIndex
|
|
from infrastructure.cache.cache_keys import (
|
|
lidarr_requested_mbids_key,
|
|
SOURCE_RESOLUTION_PREFIX,
|
|
ALBUM_INFO_PREFIX, ARTIST_INFO_PREFIX, LIDARR_PREFIX,
|
|
LIDARR_ALBUM_DETAILS_PREFIX, LIDARR_ALBUM_TRACKS_PREFIX,
|
|
LIDARR_ALBUM_TRACKFILES_PREFIX, LIDARR_ARTIST_ALBUMS_PREFIX,
|
|
LIDARR_ARTIST_DETAILS_PREFIX, LIDARR_ARTIST_IMAGE_PREFIX,
|
|
LIDARR_ALBUM_IMAGE_PREFIX, LIDARR_REQUESTED_PREFIX,
|
|
)
|
|
from infrastructure.cache.memory_cache import CacheInterface
|
|
from infrastructure.cache.disk_cache import DiskMetadataCache
|
|
from infrastructure.cover_urls import prefer_release_group_cover_url
|
|
from infrastructure.serialization import clone_with_updates
|
|
from core.exceptions import ExternalServiceError
|
|
from infrastructure.resilience.retry import CircuitOpenError
|
|
from services.cache_status_service import CacheStatusService
|
|
from services.library_precache_service import LibraryPrecacheService
|
|
|
|
if TYPE_CHECKING:
|
|
from services.preferences_service import PreferencesService
|
|
from services.local_files_service import LocalFilesService
|
|
from services.jellyfin_library_service import JellyfinLibraryService
|
|
from services.navidrome_library_service import NavidromeLibraryService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_RESOLVE_ITEMS = 50
|
|
|
|
|
|
class LibraryService:
|
|
def __init__(
|
|
self,
|
|
lidarr_repo: LidarrRepositoryProtocol,
|
|
library_db: LibraryDB,
|
|
cover_repo: CoverArtRepositoryProtocol,
|
|
preferences_service: 'PreferencesService',
|
|
memory_cache: CacheInterface | None = None,
|
|
disk_cache: DiskMetadataCache | None = None,
|
|
artist_discovery_service: Any = None,
|
|
audiodb_image_service: Any = None,
|
|
local_files_service: 'LocalFilesService | None' = None,
|
|
jellyfin_library_service: 'JellyfinLibraryService | None' = None,
|
|
navidrome_library_service: 'NavidromeLibraryService | None' = None,
|
|
sync_state_store: SyncStateStore | None = None,
|
|
genre_index: GenreIndex | None = None,
|
|
):
|
|
self._lidarr_repo = lidarr_repo
|
|
self._library_db = library_db
|
|
self._cover_repo = cover_repo
|
|
self._preferences_service = preferences_service
|
|
self._memory_cache = memory_cache
|
|
self._disk_cache = disk_cache
|
|
self._local_files_service = local_files_service
|
|
self._jellyfin_library_service = jellyfin_library_service
|
|
self._navidrome_library_service = navidrome_library_service
|
|
self._sync_state_store = sync_state_store
|
|
self._can_precache = sync_state_store is not None and genre_index is not None
|
|
self._precache_service: LibraryPrecacheService | None = None
|
|
if self._can_precache:
|
|
self._precache_service = LibraryPrecacheService(
|
|
lidarr_repo, cover_repo, preferences_service,
|
|
sync_state_store, genre_index, library_db,
|
|
artist_discovery_service=artist_discovery_service,
|
|
audiodb_image_service=audiodb_image_service,
|
|
)
|
|
self._last_sync_time: float = 0.0
|
|
self._last_manual_sync: float = 0.0
|
|
self._manual_sync_cooldown: float = 60.0
|
|
self._global_sync_cooldown: float = 30.0
|
|
self._sync_lock = asyncio.Lock()
|
|
self._sync_future: asyncio.Future | None = None
|
|
|
|
def _update_last_sync_timestamp(self) -> None:
|
|
try:
|
|
lidarr_settings = self._preferences_service.get_lidarr_settings()
|
|
updated_settings = clone_with_updates(lidarr_settings, {'last_sync': int(time.time())})
|
|
self._preferences_service.save_lidarr_settings(updated_settings)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(f"Failed to update last_sync timestamp: {e}")
|
|
|
|
@staticmethod
|
|
def _normalized_album_cover_url(album_mbid: str | None, cover_url: str | None) -> str | None:
|
|
return prefer_release_group_cover_url(album_mbid, cover_url, size=500)
|
|
|
|
async def get_library(self) -> list[LibraryAlbum]:
|
|
try:
|
|
albums_data = await self._library_db.get_albums()
|
|
|
|
if not albums_data:
|
|
logger.info("Library cache is empty, syncing from Lidarr")
|
|
await self.sync_library()
|
|
albums_data = await self._library_db.get_albums()
|
|
|
|
albums = [
|
|
LibraryAlbum(
|
|
artist=album['artist_name'],
|
|
album=album['title'],
|
|
year=album.get('year'),
|
|
monitored=bool(album.get('monitored', 1)),
|
|
quality=None,
|
|
cover_url=self._normalized_album_cover_url(
|
|
album.get('mbid'),
|
|
album.get('cover_url'),
|
|
),
|
|
musicbrainz_id=album.get('mbid'),
|
|
artist_mbid=album.get('artist_mbid'),
|
|
date_added=album.get('date_added')
|
|
)
|
|
for album in albums_data
|
|
]
|
|
|
|
return albums
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch library: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch library: {e}")
|
|
|
|
async def get_library_mbids(self) -> list[str]:
|
|
if not self._lidarr_repo.is_configured():
|
|
return []
|
|
try:
|
|
mbids_set = await self._lidarr_repo.get_library_mbids(include_release_ids=False)
|
|
return list(mbids_set)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch library mbids: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch library mbids: {e}")
|
|
|
|
async def get_requested_mbids(self) -> list[str]:
|
|
if not self._lidarr_repo.is_configured():
|
|
return []
|
|
try:
|
|
requested_set = await self._lidarr_repo.get_requested_mbids()
|
|
return list(requested_set)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch requested mbids: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch requested mbids: {e}")
|
|
|
|
async def get_artists(self, limit: int | None = None) -> list[LibraryArtist]:
|
|
try:
|
|
artists_data = await self._library_db.get_artists(limit=limit)
|
|
|
|
if not artists_data:
|
|
logger.info("Artists cache is empty, syncing from Lidarr")
|
|
await self.sync_library()
|
|
artists_data = await self._library_db.get_artists(limit=limit)
|
|
|
|
artists = [
|
|
LibraryArtist(
|
|
mbid=artist['mbid'],
|
|
name=artist['name'],
|
|
album_count=artist.get('album_count', 0),
|
|
date_added=artist.get('date_added')
|
|
)
|
|
for artist in artists_data
|
|
]
|
|
|
|
return artists
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch artists: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch artists: {e}")
|
|
|
|
async def get_albums_paginated(
|
|
self,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
sort_by: str = "date_added",
|
|
sort_order: str = "desc",
|
|
search: str | None = None,
|
|
) -> tuple[list[LibraryAlbum], int]:
|
|
try:
|
|
albums_data, total = await self._library_db.get_albums_paginated(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search,
|
|
)
|
|
|
|
if not albums_data and offset == 0 and not search:
|
|
logger.info("Library cache is empty, syncing from Lidarr")
|
|
await self.sync_library()
|
|
albums_data, total = await self._library_db.get_albums_paginated(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search,
|
|
)
|
|
|
|
albums = [
|
|
LibraryAlbum(
|
|
artist=album['artist_name'],
|
|
album=album['title'],
|
|
year=album.get('year'),
|
|
monitored=bool(album.get('monitored', 1)),
|
|
quality=None,
|
|
cover_url=self._normalized_album_cover_url(album.get('mbid'), album.get('cover_url')),
|
|
musicbrainz_id=album.get('mbid'),
|
|
artist_mbid=album.get('artist_mbid'),
|
|
date_added=album.get('date_added'),
|
|
)
|
|
for album in albums_data
|
|
]
|
|
return albums, total
|
|
except (ExternalServiceError, CircuitOpenError):
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch paginated albums: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch paginated albums: {e}")
|
|
|
|
async def get_artists_paginated(
|
|
self,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
sort_by: str = "name",
|
|
sort_order: str = "asc",
|
|
search: str | None = None,
|
|
) -> tuple[list[LibraryArtist], int]:
|
|
try:
|
|
artists_data, total = await self._library_db.get_artists_paginated(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search,
|
|
)
|
|
|
|
if not artists_data and offset == 0 and not search:
|
|
logger.info("Artists cache is empty, syncing from Lidarr")
|
|
await self.sync_library()
|
|
artists_data, total = await self._library_db.get_artists_paginated(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search,
|
|
)
|
|
|
|
artists = [
|
|
LibraryArtist(
|
|
mbid=artist['mbid'],
|
|
name=artist['name'],
|
|
album_count=artist.get('album_count', 0),
|
|
date_added=artist.get('date_added'),
|
|
)
|
|
for artist in artists_data
|
|
]
|
|
return artists, total
|
|
except (ExternalServiceError, CircuitOpenError):
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch paginated artists: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch paginated artists: {e}")
|
|
|
|
async def get_recently_added(self, limit: int = 20) -> list[LibraryAlbum]:
|
|
|
|
try:
|
|
if self._lidarr_repo.is_configured():
|
|
albums = await self._lidarr_repo.get_recently_imported(limit=limit)
|
|
else:
|
|
albums = []
|
|
|
|
if not albums:
|
|
logger.info("No recent imports from history, falling back to cache")
|
|
albums_data = await self._library_db.get_recently_added(limit=limit)
|
|
|
|
albums = [
|
|
LibraryAlbum(
|
|
artist=album['artist_name'],
|
|
album=album['title'],
|
|
year=album.get('year'),
|
|
monitored=bool(album.get('monitored', 1)),
|
|
quality=None,
|
|
cover_url=self._normalized_album_cover_url(
|
|
album.get('mbid'),
|
|
album.get('cover_url'),
|
|
),
|
|
musicbrainz_id=album.get('mbid'),
|
|
artist_mbid=album.get('artist_mbid'),
|
|
date_added=album.get('date_added')
|
|
)
|
|
for album in albums_data
|
|
]
|
|
|
|
return albums
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch recently added: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch recently added: {e}")
|
|
|
|
async def sync_library(self, is_manual: bool = False, force_full: bool = False) -> SyncLibraryResponse:
|
|
from services.cache_status_service import CacheStatusService
|
|
|
|
if not self._lidarr_repo.is_configured():
|
|
raise ExternalServiceError("Lidarr is not configured. Set a Lidarr API key in Settings to sync your library.")
|
|
|
|
try:
|
|
status_service = CacheStatusService()
|
|
|
|
async with self._sync_lock:
|
|
current_time = time.time()
|
|
|
|
time_since_last_sync = current_time - self._last_sync_time
|
|
if time_since_last_sync < self._global_sync_cooldown:
|
|
remaining = int(self._global_sync_cooldown - time_since_last_sync)
|
|
logger.info(f"Global sync cooldown active ({remaining}s remaining). Skipping sync.")
|
|
raise ExternalServiceError(
|
|
f"Sync cooldown active. Please wait {remaining} seconds before syncing again."
|
|
)
|
|
|
|
if is_manual:
|
|
time_since_last_manual = current_time - self._last_manual_sync
|
|
if time_since_last_manual < self._manual_sync_cooldown:
|
|
remaining = int(self._manual_sync_cooldown - time_since_last_manual)
|
|
raise ExternalServiceError(
|
|
f"Manual sync cooldown active. Please wait {remaining} seconds before syncing again."
|
|
)
|
|
|
|
if status_service.is_syncing():
|
|
if is_manual:
|
|
logger.warning("Library sync already in progress - cancelling previous sync to start fresh")
|
|
await status_service.cancel_current_sync()
|
|
await status_service.wait_for_completion()
|
|
else:
|
|
logger.info("Library sync already in progress - skipping auto-sync")
|
|
return SyncLibraryResponse(status="skipped", artists=0, albums=0)
|
|
|
|
if self._sync_future is not None and not self._sync_future.done():
|
|
existing_future = self._sync_future
|
|
else:
|
|
existing_future = None
|
|
loop = asyncio.get_running_loop()
|
|
self._sync_future = loop.create_future()
|
|
|
|
# Shield so waiter cancellation doesn't poison the shared future
|
|
if existing_future is not None:
|
|
return await asyncio.shield(existing_future)
|
|
|
|
sync_succeeded = False
|
|
try:
|
|
logger.info("Starting library sync from Lidarr")
|
|
|
|
albums = await self._lidarr_repo.get_library()
|
|
artists = await self._lidarr_repo.get_artists_from_library()
|
|
|
|
albums_data = [
|
|
{
|
|
'mbid': album.musicbrainz_id or f"unknown_{album.album}",
|
|
'artist_mbid': album.artist_mbid,
|
|
'artist_name': album.artist,
|
|
'title': album.album,
|
|
'year': album.year,
|
|
'cover_url': self._normalized_album_cover_url(
|
|
album.musicbrainz_id,
|
|
album.cover_url,
|
|
),
|
|
'monitored': album.monitored,
|
|
'date_added': album.date_added
|
|
}
|
|
for album in albums
|
|
]
|
|
|
|
await self._library_db.save_library(artists, albums_data)
|
|
logger.info("Library cache updated - unmonitored items removed")
|
|
|
|
now = time.time()
|
|
self._last_sync_time = now
|
|
if is_manual:
|
|
self._last_manual_sync = now
|
|
|
|
if self._precache_service is None:
|
|
logger.warning("Precache skipped: sync_state_store/genre_index not provided")
|
|
self._update_last_sync_timestamp()
|
|
result = SyncLibraryResponse(status='success', artists=len(artists), albums=len(albums))
|
|
self._sync_future.set_result(result)
|
|
return result
|
|
|
|
resume = False
|
|
if not force_full and self._sync_state_store:
|
|
try:
|
|
last_state = await self._sync_state_store.get_sync_state()
|
|
if last_state and last_state.get('status') == 'failed':
|
|
resume = True
|
|
logger.info("Previous sync failed, resuming from checkpoint")
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("Failed to check sync state for resume: %s", e)
|
|
|
|
if force_full and self._sync_state_store:
|
|
try:
|
|
await self._sync_state_store.clear_processed_items()
|
|
await self._sync_state_store.clear_sync_state()
|
|
logger.info("Force full sync: cleared previous progress")
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("Failed to clear sync state for force_full: %s", e)
|
|
|
|
task = asyncio.create_task(self._precache_service.precache_library_resources(artists, albums, resume=resume))
|
|
|
|
def on_task_done(t: asyncio.Task):
|
|
try:
|
|
exc = t.exception()
|
|
if exc:
|
|
logger.error(f"Precache task failed: {exc}")
|
|
except asyncio.CancelledError:
|
|
logger.info("Precache task was cancelled")
|
|
finally:
|
|
status_service.set_current_task(None)
|
|
|
|
task.add_done_callback(on_task_done)
|
|
status_service.set_current_task(task)
|
|
|
|
logger.info(f"Library sync complete: {len(artists)} artists, {len(albums)} albums")
|
|
|
|
self._update_last_sync_timestamp()
|
|
|
|
result = SyncLibraryResponse(
|
|
status='success',
|
|
artists=len(artists),
|
|
albums=len(albums),
|
|
)
|
|
sync_succeeded = True
|
|
self._sync_future.set_result(result)
|
|
return result
|
|
except BaseException as exc:
|
|
if self._sync_future is not None and not self._sync_future.done():
|
|
self._sync_future.set_exception(exc)
|
|
raise
|
|
finally:
|
|
if not sync_succeeded:
|
|
future = self._sync_future
|
|
self._sync_future = None
|
|
# Suppress "Future exception was never retrieved" if no waiter
|
|
if future is not None and future.done() and not future.cancelled():
|
|
try:
|
|
future.exception()
|
|
except BaseException:
|
|
pass
|
|
except (ExternalServiceError, CircuitOpenError):
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Couldn't sync the library: {e}")
|
|
raise ExternalServiceError(f"Couldn't sync the library: {e}")
|
|
|
|
async def get_stats(self) -> LibraryStatsResponse:
|
|
try:
|
|
stats = await self._library_db.get_stats()
|
|
|
|
return LibraryStatsResponse(
|
|
artist_count=stats['artist_count'],
|
|
album_count=stats['album_count'],
|
|
last_sync=stats['last_sync'],
|
|
db_size_bytes=stats['db_size_bytes'],
|
|
db_size_mb=round(stats['db_size_bytes'] / (1024 * 1024), 2)
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch library stats: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch library stats: {e}")
|
|
|
|
async def clear_cache(self) -> None:
|
|
try:
|
|
await self._library_db.clear()
|
|
logger.info("Library cache cleared")
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to clear library cache: {e}")
|
|
raise ExternalServiceError(f"Failed to clear library cache: {e}")
|
|
|
|
async def get_library_grouped(self) -> list[LibraryGroupedArtist]:
|
|
if not self._lidarr_repo.is_configured():
|
|
return []
|
|
try:
|
|
return await self._lidarr_repo.get_library_grouped()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch grouped library: {e}")
|
|
raise ExternalServiceError(f"Failed to fetch grouped library: {e}")
|
|
|
|
async def get_album_removal_preview(self, album_mbid: str) -> AlbumRemovePreviewResponse:
|
|
try:
|
|
album_data = await self._lidarr_repo.get_album_details(album_mbid)
|
|
if not album_data or not album_data.get("id"):
|
|
raise ExternalServiceError(f"Album not found in Lidarr: {album_mbid}")
|
|
|
|
artist_mbid = album_data.get("artist_mbid")
|
|
artist_name = album_data.get("artist_name", "Unknown")
|
|
|
|
artist_will_be_removed = False
|
|
if artist_mbid:
|
|
artist_albums = await self._lidarr_repo.get_artist_albums(artist_mbid)
|
|
monitored_count = sum(1 for album in artist_albums if album.get("monitored"))
|
|
artist_will_be_removed = monitored_count <= 1
|
|
|
|
return AlbumRemovePreviewResponse(
|
|
success=True,
|
|
artist_will_be_removed=artist_will_be_removed,
|
|
artist_name=artist_name if artist_will_be_removed else None,
|
|
)
|
|
except ExternalServiceError:
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to build removal preview for album {album_mbid}: {e}")
|
|
raise ExternalServiceError(f"Failed to load removal preview: {e}")
|
|
|
|
async def remove_album(self, album_mbid: str, delete_files: bool = False) -> AlbumRemoveResponse:
|
|
try:
|
|
album_data = await self._lidarr_repo.get_album_details(album_mbid)
|
|
if not album_data or not album_data.get("id"):
|
|
raise ExternalServiceError(f"Album not found in Lidarr: {album_mbid}")
|
|
|
|
album_id = album_data["id"]
|
|
artist_mbid = album_data.get("artist_mbid")
|
|
artist_name = album_data.get("artist_name", "Unknown")
|
|
|
|
await self._lidarr_repo.delete_album(album_id, delete_files=delete_files)
|
|
|
|
artist_removed = False
|
|
if artist_mbid:
|
|
try:
|
|
if self._memory_cache:
|
|
await asyncio.gather(
|
|
self._memory_cache.delete(f"lidarr_artist_albums:{artist_mbid}"),
|
|
self._memory_cache.delete(f"lidarr_artist_details:{artist_mbid}"),
|
|
)
|
|
artist_albums = await self._lidarr_repo.get_artist_albums(artist_mbid)
|
|
if not any(a.get("monitored") for a in artist_albums):
|
|
artist_details = await self._lidarr_repo.get_artist_details(artist_mbid)
|
|
if artist_details and artist_details.get("id"):
|
|
await self._lidarr_repo.delete_artist(
|
|
artist_details["id"], delete_files=delete_files
|
|
)
|
|
artist_removed = True
|
|
logger.info(f"Auto-removed artist '{artist_name}' (no remaining albums)")
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(
|
|
f"Album '{album_mbid}' removed but artist cleanup failed for '{artist_mbid}': {e}"
|
|
)
|
|
|
|
try:
|
|
await self._invalidate_caches_after_removal(album_mbid, artist_mbid, artist_removed=artist_removed)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(f"Album '{album_mbid}' removed but cache invalidation failed: {e}")
|
|
|
|
return AlbumRemoveResponse(
|
|
success=True,
|
|
artist_removed=artist_removed,
|
|
artist_name=artist_name if artist_removed else None,
|
|
)
|
|
except ExternalServiceError:
|
|
raise
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Couldn't remove album {album_mbid}: {e}")
|
|
raise ExternalServiceError(f"Couldn't remove this album: {e}")
|
|
|
|
async def _invalidate_caches_after_removal(self, album_mbid: str, artist_mbid: str | None, *, artist_removed: bool = False) -> None:
|
|
await self._library_db.clear()
|
|
|
|
if self._memory_cache:
|
|
keys_to_delete = [
|
|
f"{ALBUM_INFO_PREFIX}{album_mbid}",
|
|
f"{LIDARR_ALBUM_DETAILS_PREFIX}{album_mbid}",
|
|
lidarr_requested_mbids_key(),
|
|
]
|
|
if artist_mbid:
|
|
keys_to_delete.extend([
|
|
f"{LIDARR_ARTIST_ALBUMS_PREFIX}{artist_mbid}",
|
|
f"{LIDARR_ARTIST_DETAILS_PREFIX}{artist_mbid}",
|
|
f"{ARTIST_INFO_PREFIX}{artist_mbid}",
|
|
])
|
|
await asyncio.gather(
|
|
*[self._memory_cache.delete(k) for k in keys_to_delete],
|
|
self._memory_cache.clear_prefix(f"{LIDARR_PREFIX}library:"),
|
|
self._memory_cache.clear_prefix(f"{LIDARR_PREFIX}artists:"),
|
|
self._memory_cache.clear_prefix(LIDARR_ALBUM_IMAGE_PREFIX),
|
|
self._memory_cache.clear_prefix(LIDARR_ALBUM_DETAILS_PREFIX),
|
|
self._memory_cache.clear_prefix(LIDARR_ALBUM_TRACKS_PREFIX),
|
|
self._memory_cache.clear_prefix(LIDARR_ALBUM_TRACKFILES_PREFIX),
|
|
self._memory_cache.clear_prefix(LIDARR_REQUESTED_PREFIX),
|
|
self._memory_cache.clear_prefix(LIDARR_ARTIST_IMAGE_PREFIX),
|
|
self._memory_cache.clear_prefix(LIDARR_ARTIST_DETAILS_PREFIX),
|
|
self._memory_cache.clear_prefix(LIDARR_ARTIST_ALBUMS_PREFIX),
|
|
)
|
|
|
|
if self._disk_cache:
|
|
coros = [self._disk_cache.delete_album(album_mbid)]
|
|
if artist_mbid:
|
|
coros.append(self._disk_cache.delete_artist(artist_mbid))
|
|
await asyncio.gather(*coros)
|
|
|
|
if self._cover_repo:
|
|
try:
|
|
await self._cover_repo.delete_covers_for_album(album_mbid)
|
|
if artist_mbid and artist_removed:
|
|
await self._cover_repo.delete_covers_for_artist(artist_mbid)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("Failed to clean up cover images after removal", exc_info=True)
|
|
|
|
async def _resolve_album_tracks(
|
|
self,
|
|
album_mbid: str,
|
|
) -> dict[str, tuple[str, str, str | None, float | None]]:
|
|
"""Resolve album MBID to {disc:track: (source, source_id, format, duration)}.
|
|
|
|
Priority: local → navidrome → jellyfin.
|
|
Uses source_resolution cache (1h TTL).
|
|
"""
|
|
if self._memory_cache is None:
|
|
raise ExternalServiceError("Memory cache not available for track resolution")
|
|
|
|
cache_key = f"{SOURCE_RESOLUTION_PREFIX}_tracks:{album_mbid}"
|
|
cached = await self._memory_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
result: dict[str, tuple[str, str, str | None, float | None]] = {}
|
|
|
|
def _track_key(disc: int, track: int) -> str:
|
|
return f"{disc}:{track}"
|
|
|
|
if self._local_files_service:
|
|
try:
|
|
match = await self._local_files_service.match_album_by_mbid(album_mbid)
|
|
if match.found:
|
|
for t in match.tracks:
|
|
key = _track_key(getattr(t, "disc_number", 1) or 1, t.track_number)
|
|
if key not in result:
|
|
result[key] = (
|
|
"local",
|
|
str(t.track_file_id),
|
|
t.format or None,
|
|
t.duration_seconds,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("Local track resolution failed for %s", album_mbid, exc_info=True)
|
|
|
|
nd_enabled = False
|
|
try:
|
|
nd_settings = self._preferences_service.get_navidrome_connection_raw()
|
|
nd_enabled = nd_settings.enabled
|
|
except AttributeError:
|
|
logger.debug("Navidrome settings unavailable during track resolution", exc_info=True)
|
|
|
|
if nd_enabled and self._navidrome_library_service:
|
|
try:
|
|
nav_id = self._navidrome_library_service.lookup_navidrome_id(album_mbid)
|
|
if nav_id:
|
|
detail = await self._navidrome_library_service.get_album_detail(nav_id)
|
|
if detail:
|
|
for t in detail.tracks:
|
|
key = _track_key(getattr(t, "disc_number", 1) or 1, t.track_number)
|
|
if key not in result:
|
|
result[key] = (
|
|
"navidrome",
|
|
t.navidrome_id,
|
|
t.codec,
|
|
t.duration_seconds,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("Navidrome track resolution failed for %s", album_mbid, exc_info=True)
|
|
|
|
jf_enabled = False
|
|
try:
|
|
jf_settings = self._preferences_service.get_jellyfin_connection()
|
|
jf_enabled = jf_settings.enabled
|
|
except AttributeError:
|
|
logger.debug("Jellyfin settings unavailable during track resolution", exc_info=True)
|
|
|
|
if jf_enabled and self._jellyfin_library_service:
|
|
try:
|
|
match = await self._jellyfin_library_service.match_album_by_mbid(album_mbid)
|
|
if match.found:
|
|
all_same = len(match.tracks) > 1 and len({t.track_number for t in match.tracks}) == 1
|
|
if not all_same:
|
|
for t in match.tracks:
|
|
key = _track_key(getattr(t, "disc_number", 1) or 1, t.track_number)
|
|
if key not in result:
|
|
result[key] = (
|
|
"jellyfin",
|
|
t.jellyfin_id,
|
|
t.codec,
|
|
t.duration_seconds,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("Jellyfin track resolution failed for %s", album_mbid, exc_info=True)
|
|
|
|
await self._memory_cache.set(cache_key, result, ttl_seconds=3600)
|
|
return result
|
|
|
|
async def resolve_tracks_batch(
|
|
self,
|
|
items: list,
|
|
) -> TrackResolveResponse:
|
|
"""Resolve a batch of track items to stream URLs."""
|
|
items = items[:MAX_RESOLVE_ITEMS]
|
|
if not items:
|
|
return TrackResolveResponse(items=[])
|
|
|
|
album_mbids = {it.release_group_mbid for it in items if it.release_group_mbid}
|
|
|
|
sem = asyncio.Semaphore(5)
|
|
|
|
async def _resolve_one(mbid: str) -> tuple[str, dict]:
|
|
async with sem:
|
|
return mbid, await self._resolve_album_tracks(mbid)
|
|
|
|
tasks = [_resolve_one(mbid) for mbid in album_mbids]
|
|
album_maps: dict[str, dict] = {}
|
|
for r in await asyncio.gather(*tasks, return_exceptions=True):
|
|
if isinstance(r, Exception):
|
|
logger.warning("Album resolution failed: %s", r)
|
|
continue
|
|
mbid, track_map = r
|
|
album_maps[mbid] = track_map
|
|
|
|
resolved: list[ResolvedTrack] = []
|
|
for item in items:
|
|
base = ResolvedTrack(
|
|
release_group_mbid=item.release_group_mbid,
|
|
disc_number=item.disc_number,
|
|
track_number=item.track_number,
|
|
)
|
|
|
|
if not item.release_group_mbid or item.track_number is None:
|
|
resolved.append(base)
|
|
continue
|
|
|
|
track_map = album_maps.get(item.release_group_mbid, {})
|
|
lookup_key = f"{item.disc_number or 1}:{item.track_number}"
|
|
match = track_map.get(lookup_key)
|
|
if not match:
|
|
resolved.append(base)
|
|
continue
|
|
|
|
source, source_id, fmt, duration = match
|
|
stream_url = None
|
|
if source == "local":
|
|
stream_url = f"/api/v1/stream/local/{source_id}"
|
|
elif source == "navidrome":
|
|
stream_url = f"/api/v1/stream/navidrome/{source_id}"
|
|
elif source == "jellyfin":
|
|
stream_url = f"/api/v1/stream/jellyfin/{source_id}"
|
|
|
|
resolved.append(ResolvedTrack(
|
|
release_group_mbid=item.release_group_mbid,
|
|
disc_number=item.disc_number,
|
|
track_number=item.track_number,
|
|
source=source,
|
|
track_source_id=source_id,
|
|
stream_url=stream_url,
|
|
format=fmt,
|
|
duration=duration,
|
|
))
|
|
|
|
return TrackResolveResponse(items=resolved)
|