import asyncio import logging import re from collections import defaultdict from difflib import SequenceMatcher from pathlib import Path from typing import Optional from core.exceptions import InvalidPlaylistDataError, PlaylistNotFoundError, SourceResolutionError from infrastructure.cache.cache_keys import SOURCE_RESOLUTION_PREFIX from infrastructure.cache.memory_cache import CacheInterface from repositories.async_playlist_repository import AsyncPlaylistRepository from repositories.playlist_repository import ( PlaylistRecord, PlaylistRepository, PlaylistSummaryRecord, PlaylistTrackRecord, ) logger = logging.getLogger(__name__) ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} MAX_COVER_SIZE = 2 * 1024 * 1024 # 2 MB _MIME_TO_EXT = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} _SAFE_ID_RE = re.compile(r"^[a-f0-9\-]+$") VALID_SOURCE_TYPES = {"local", "jellyfin", "navidrome", "youtube", ""} MAX_NAME_LENGTH = 100 _SOURCE_TYPE_ALIASES = { "local": "local", "howler": "local", "jellyfin": "jellyfin", "navidrome": "navidrome", "youtube": "youtube", "": "", } def _normalize_source_map(by_num: dict) -> dict[int, tuple[str, str]]: """Ensure source map keys are ints (guards against cached string keys).""" if not by_num: return by_num first_key = next(iter(by_num)) if isinstance(first_key, int): return by_num normalized: dict[int, tuple[str, str]] = {} for k, v in by_num.items(): try: normalized[int(k)] = v except (TypeError, ValueError): continue return normalized def _safe_track_number(value: object) -> int | None: """Coerce a track number to int, returning None for non-numeric inputs.""" if isinstance(value, int): return value try: return int(value) except (TypeError, ValueError): return None def _fuzzy_name_match(name1: str, name2: str) -> bool: if not name1 or not name2: return False n1, n2 = name1.lower().strip(), name2.lower().strip() if n1 == n2: return True if n1 in n2 or n2 in n1: return True return SequenceMatcher(None, n1, n2).ratio() > 0.6 class PlaylistService: def __init__(self, repo: PlaylistRepository, cache_dir: Path, cache: Optional[CacheInterface] = None): self._repo = AsyncPlaylistRepository(repo) self._cover_dir = cache_dir / "covers" / "playlists" self._cache = cache async def create_playlist(self, name: str) -> PlaylistRecord: stripped = name.strip() if name else "" if not stripped: raise InvalidPlaylistDataError("Playlist name must not be empty") if len(stripped) > MAX_NAME_LENGTH: raise InvalidPlaylistDataError(f"Playlist name must not exceed {MAX_NAME_LENGTH} characters") result = await self._repo.create_playlist(stripped) logger.info("Playlist created: id=%s name=%s", result.id, result.name) return result async def get_playlist(self, playlist_id: str) -> PlaylistRecord: result = await self._repo.get_playlist(playlist_id) if result is None: raise PlaylistNotFoundError(f"Playlist {playlist_id} not found") return result async def get_all_playlists(self) -> list[PlaylistSummaryRecord]: return await self._repo.get_all_playlists() async def get_playlist_with_tracks( self, playlist_id: str, ) -> tuple[PlaylistRecord, list[PlaylistTrackRecord]]: playlist = await self.get_playlist(playlist_id) tracks = await self._repo.get_tracks(playlist_id) return playlist, tracks async def update_playlist( self, playlist_id: str, name: Optional[str] = None, ) -> PlaylistRecord: if name is not None: stripped = name.strip() if not stripped: raise InvalidPlaylistDataError("Playlist name must not be empty") if len(stripped) > MAX_NAME_LENGTH: raise InvalidPlaylistDataError(f"Playlist name must not exceed {MAX_NAME_LENGTH} characters") name = stripped result = await self._repo.update_playlist(playlist_id, name=name) if result is None: raise PlaylistNotFoundError(f"Playlist {playlist_id} not found") logger.info("Playlist updated: id=%s", playlist_id) return result async def update_playlist_with_detail( self, playlist_id: str, name: Optional[str] = None, ) -> tuple[PlaylistRecord, list[PlaylistTrackRecord]]: playlist = await self.update_playlist(playlist_id, name=name) tracks = await self._repo.get_tracks(playlist_id) return playlist, tracks async def delete_playlist(self, playlist_id: str) -> None: deleted = await self._repo.delete_playlist(playlist_id) if not deleted: raise PlaylistNotFoundError(f"Playlist {playlist_id} not found") await asyncio.to_thread(self._delete_cover_file, playlist_id) logger.info("Playlist deleted: id=%s", playlist_id) async def add_tracks( self, playlist_id: str, tracks: list[dict], position: Optional[int] = None, ) -> list[PlaylistTrackRecord]: if not tracks: raise InvalidPlaylistDataError("Track list must not be empty") normalized_tracks: list[dict] = [] for track in tracks: normalized = dict(track) st = normalized.get("source_type", "") if st and st not in _SOURCE_TYPE_ALIASES: raise InvalidPlaylistDataError( f"Invalid source_type '{st}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 ) normalized["source_type"] = _SOURCE_TYPE_ALIASES.get(st, st) sources = normalized.get("available_sources") if sources is not None: normalized_sources: list[str] = [] for source in sources: if source not in _SOURCE_TYPE_ALIASES: raise InvalidPlaylistDataError( f"Invalid available source '{source}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 ) normalized_sources.append(_SOURCE_TYPE_ALIASES[source]) normalized["available_sources"] = normalized_sources normalized_tracks.append(normalized) await self.get_playlist(playlist_id) result = await self._repo.add_tracks(playlist_id, normalized_tracks, position) logger.info("Added %d tracks to playlist %s", len(result), playlist_id) return result async def remove_track(self, playlist_id: str, track_id: str) -> None: removed = await self._repo.remove_track(playlist_id, track_id) if not removed: raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") logger.info("Removed track %s from playlist %s", track_id, playlist_id) async def remove_tracks(self, playlist_id: str, track_ids: list[str]) -> int: if not track_ids: raise InvalidPlaylistDataError("No track IDs provided") removed = await self._repo.remove_tracks(playlist_id, track_ids) if not removed: raise PlaylistNotFoundError(f"No matching tracks found in playlist {playlist_id}") logger.info("Removed %d tracks from playlist %s", removed, playlist_id) return removed async def reorder_track( self, playlist_id: str, track_id: str, new_position: int, ) -> int: if new_position < 0: raise InvalidPlaylistDataError("Position must be >= 0") result = await self._repo.reorder_track(playlist_id, track_id, new_position) if result is None: raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") logger.info("Reordered track %s to position %d in playlist %s", track_id, result, playlist_id) return result async def update_track_source( self, playlist_id: str, track_id: str, source_type: Optional[str] = None, available_sources: Optional[list[str]] = None, jf_service: object = None, local_service: object = None, nd_service: object = None, ) -> PlaylistTrackRecord: if source_type is not None and source_type not in _SOURCE_TYPE_ALIASES: raise InvalidPlaylistDataError( f"Invalid source_type '{source_type}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 ) normalized_source = _SOURCE_TYPE_ALIASES.get(source_type, source_type) normalized_available_sources = available_sources if available_sources is not None: normalized_available_sources = [] for source in available_sources: if source not in _SOURCE_TYPE_ALIASES: raise InvalidPlaylistDataError( f"Invalid available source '{source}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 ) normalized_available_sources.append(_SOURCE_TYPE_ALIASES[source]) new_track_source_id: Optional[str] = None if normalized_source: current_track = await self._repo.get_track(playlist_id, track_id) if current_track is None: raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") if normalized_source != current_track.source_type: new_track_source_id = await self._resolve_new_source_id( current_track, normalized_source, jf_service, local_service, nd_service, ) result = await self._repo.update_track_source( playlist_id, track_id, normalized_source, normalized_available_sources, track_source_id=new_track_source_id, ) if result is None: raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") logger.info("Updated track source: track=%s playlist=%s", track_id, playlist_id) return result async def get_tracks(self, playlist_id: str) -> list[PlaylistTrackRecord]: return await self._repo.get_tracks(playlist_id) async def check_track_membership( self, tracks: list[tuple[str, str, str]], ) -> dict[str, list[int]]: return await self._repo.check_track_membership(tracks) async def resolve_track_sources( self, playlist_id: str, jf_service: object = None, local_service: object = None, nd_service: object = None, ) -> dict[str, list[str]]: await self.get_playlist(playlist_id) tracks = await self._repo.get_tracks(playlist_id) if not tracks: return {} album_groups: dict[str, list[PlaylistTrackRecord]] = defaultdict(list) no_album_tracks: list[PlaylistTrackRecord] = [] for t in tracks: if t.album_id and t.track_number is not None: album_groups[t.album_id].append(t) else: no_album_tracks.append(t) result: dict[str, list[str]] = {} for album_id, album_tracks in album_groups.items(): representative = album_tracks[0] jf_by_num, local_by_num, nd_by_num = await self._resolve_album_sources( album_id, jf_service, local_service, nd_service, album_name=representative.album_name or "", artist_name=representative.artist_name or "", ) for t in album_tracks: sources = set() if t.source_type: sources.add(t.source_type) jf_track = jf_by_num.get(t.track_number) if jf_track and _fuzzy_name_match(t.track_name, jf_track[0]): sources.add("jellyfin") local_track = local_by_num.get(t.track_number) if local_track and _fuzzy_name_match(t.track_name, local_track[0]): sources.add("local") nd_track = nd_by_num.get(t.track_number) if nd_track and _fuzzy_name_match(t.track_name, nd_track[0]): sources.add("navidrome") result[t.id] = sorted(sources) for t in no_album_tracks: result[t.id] = [t.source_type] if t.source_type else [] persist_updates: dict[str, list[str]] = {} for t in tracks: resolved = result.get(t.id) if not resolved: continue existing = set(t.available_sources) if t.available_sources else set() if set(resolved) >= existing and set(resolved) != existing: persist_updates[t.id] = resolved if persist_updates: await self._repo.batch_update_available_sources(playlist_id, persist_updates) return result async def _resolve_album_sources( self, album_id: str, jf_service: object, local_service: object, nd_service: object = None, album_name: str = "", artist_name: str = "", ) -> tuple[dict[int, tuple[str, str]], dict[int, tuple[str, str]], dict[int, tuple[str, str]]]: cache_key = f"{SOURCE_RESOLUTION_PREFIX}:{album_id}" if self._cache: cached = await self._cache.get(cache_key) if cached is not None: if len(cached) == 2: return (_normalize_source_map(cached[0]), _normalize_source_map(cached[1]), {}) return ( _normalize_source_map(cached[0]), _normalize_source_map(cached[1]), _normalize_source_map(cached[2]), ) jf_by_num: dict[int, tuple[str, str]] = {} local_by_num: dict[int, tuple[str, str]] = {} nd_by_num: dict[int, tuple[str, str]] = {} if jf_service is not None: try: match = await jf_service.match_album_by_mbid(album_id) if match.found: for t in match.tracks: key = _safe_track_number(t.track_number) if key is not None: jf_by_num[key] = (t.title, t.jellyfin_id) except Exception: # noqa: BLE001 logger.debug("Jellyfin source resolution failed for album %s", album_id, exc_info=True) if local_service is not None: try: match = await local_service.match_album_by_mbid(album_id) if match.found: for t in match.tracks: key = _safe_track_number(t.track_number) if key is not None: local_by_num[key] = (t.title, str(t.track_file_id)) except Exception: # noqa: BLE001 logger.debug("Local source resolution failed for album %s", album_id, exc_info=True) if nd_service is not None: try: match = await nd_service.get_album_match( album_id=album_id, album_name=album_name, artist_name=artist_name, ) if match.found: for t in match.tracks: key = _safe_track_number(t.track_number) if key is not None: nd_by_num[key] = (t.title, t.navidrome_id) except Exception: # noqa: BLE001 logger.debug("Navidrome source resolution failed for album %s", album_id, exc_info=True) resolved = (jf_by_num, local_by_num, nd_by_num) if self._cache: await self._cache.set(cache_key, resolved, ttl_seconds=3600) return resolved async def _resolve_new_source_id( self, track: PlaylistTrackRecord, new_source_type: str, jf_service: object, local_service: object, nd_service: object = None, ) -> str: if not track.album_id or track.track_number is None: raise SourceResolutionError( f"Cannot switch source for track '{track.track_name}': missing album_id or track_number" ) jf_by_num, local_by_num, nd_by_num = await self._resolve_album_sources( track.album_id, jf_service, local_service, nd_service, album_name=track.album_name or "", artist_name=track.artist_name or "", ) if new_source_type == "jellyfin": match_info = jf_by_num.get(track.track_number) if match_info and _fuzzy_name_match(track.track_name, match_info[0]): return match_info[1] raise SourceResolutionError( f"Track '{track.track_name}' not found in Jellyfin for album {track.album_id}" ) if new_source_type == "local": match_info = local_by_num.get(track.track_number) if match_info and _fuzzy_name_match(track.track_name, match_info[0]): return match_info[1] raise SourceResolutionError( f"Track '{track.track_name}' not found in local files for album {track.album_id}" ) if new_source_type == "navidrome": match_info = nd_by_num.get(track.track_number) if match_info and _fuzzy_name_match(track.track_name, match_info[0]): return match_info[1] raise SourceResolutionError( f"Track '{track.track_name}' not found in Navidrome for album {track.album_id}" ) raise SourceResolutionError(f"Unsupported source type for resolution: {new_source_type}") async def upload_cover( self, playlist_id: str, data: bytes, content_type: str, ) -> str: await self.get_playlist(playlist_id) self._validate_cover_id(playlist_id) if content_type not in ALLOWED_IMAGE_TYPES: raise InvalidPlaylistDataError( f"Invalid image type. Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}" ) if len(data) > MAX_COVER_SIZE: raise InvalidPlaylistDataError("Image too large. Maximum size is 2 MB") # defence-in-depth ext = _MIME_TO_EXT.get(content_type, ".jpg") file_path = self._cover_dir / f"{playlist_id}{ext}" def _write_cover() -> None: self._cover_dir.mkdir(parents=True, exist_ok=True) for old in self._cover_dir.glob(f"{playlist_id}.*"): try: old.unlink() except OSError: pass file_path.write_bytes(data) await asyncio.to_thread(_write_cover) cover_path = str(file_path) await self._repo.update_playlist( playlist_id, cover_image_path=cover_path, ) cover_url = f"/api/v1/playlists/{playlist_id}/cover" return cover_url async def get_cover_path(self, playlist_id: str) -> Optional[Path]: playlist = await self.get_playlist(playlist_id) if not playlist.cover_image_path: return None path = Path(playlist.cover_image_path) exists = await asyncio.to_thread(path.exists) if exists: return path return None async def remove_cover(self, playlist_id: str) -> None: playlist = await self.get_playlist(playlist_id) if playlist.cover_image_path: cover_path = Path(playlist.cover_image_path) try: await asyncio.to_thread(cover_path.unlink, True) except OSError: pass await self._repo.update_playlist( playlist_id, cover_image_path=None, ) @staticmethod def _validate_cover_id(playlist_id: str) -> None: if not _SAFE_ID_RE.match(playlist_id): raise InvalidPlaylistDataError("Invalid playlist ID for cover path") def _delete_cover_file(self, playlist_id: str) -> None: if not _SAFE_ID_RE.match(playlist_id): return for f in self._cover_dir.glob(f"{playlist_id}.*"): try: f.unlink() except OSError: pass