"""Deezer playlist synchronizer. Mirrors Sony.Filtr.PlaylistSynchronization.Synchronizer.DeezerSynchronizer from .NET. Algorithm: 1. Fetch target Deezer playlist. 2. For each source track with an ISRC: a. Check the local ISRC cache (tblPlaylistSynchronizationTrack). b. If not cached (or older than 7 days), look up via Deezer ISRC API and save. c. Special: skip 7-day filter for "big static playlist" accounts. 3. Build new track ID list (only tracks with a matched Deezer ID, deduplicated). 4. Diff: tracks to remove = existing_ids - new_ids; tracks to add = new_ids - existing. 5. Delete in batches of 100, add in batches of 100. 6. Order all tracks (full list). 7. Update title/description if copy mode != NoUpdate. 8. Return SyncCounts. """ import asyncio import logging from collections.abc import Iterator from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from playlist_sync.clients import deezer_client from playlist_sync.models.enums import FieldCopyMode, ServiceType, SyncError from playlist_sync.models.generic_playlist import GenericPlaylist from playlist_sync.models.playlist_sync_result import ( PlaylistSynchronizationResult, SyncCounts, ) from playlist_sync.models.service_account import ServiceAccount from playlist_sync.models.sync_task import PlaylistSynchronization from playlist_sync.services import isrc_cache_service from .base import BaseSynchronizer, warn_if_source_has_duplicates logger = logging.getLogger("playlist_sync.deezer_synchronizer") _ISRC_CACHE_MAX_AGE_DAYS = 7 # Accounts whose playlists are large and static — ISRC cache is never expired for these. # Mirrors _bigStaticPlaylistUsers in .NET DeezerSynchronizer. _BIG_STATIC_PLAYLIST_USERS = ["europa.kinderprogramm", "die_drei_fragezeichen"] def _chunk(lst: list, size: int) -> Iterator[list]: for i in range(0, len(lst), size): yield lst[i : i + size] class DeezerSynchronizer(BaseSynchronizer): """Deezer playlist synchronizer.""" async def _copy_internal( self, generic_playlist: GenericPlaylist, sync: PlaylistSynchronization, service_account: ServiceAccount, session: AsyncSession, ) -> PlaylistSynchronizationResult: playlist_id = int(sync.to_playlist_id) logger.debug( "DeezerSynchronizer: copying '%s' to Deezer playlist %s", generic_playlist.name, playlist_id, ) warn_if_source_has_duplicates(generic_playlist.tracks, "Deezer", sync.id) access_token = service_account.access_token if access_token is None: return PlaylistSynchronizationResult.from_error( SyncError.Unauthorized, "Service account has no access token", ) # 1. Fetch target Deezer playlist deezer_playlist = await deezer_client.get_playlist(playlist_id, access_token) if not deezer_playlist or not deezer_playlist.get("id"): return PlaylistSynchronizationResult.from_error( SyncError.NoTargetPlaylist, f"Could not find Deezer playlist with id {playlist_id}", ) sync.to_playlist_title = deezer_playlist.get("title") sync.to_playlist_image = ( deezer_playlist.get("picture") or f"https://api.deezer.com/playlist/{playlist_id}/image" ) # 2. Resolve ISRC → Deezer ID for all source tracks await self._set_deezer_track_ids(generic_playlist, access_token, session) # 3. Build new track ID list (order-preserving dedupe, skip unresolved tracks) seen_deezer_ids: set = set() new_track_ids: list = [] for t in generic_playlist.tracks: if t.deezer_id and t.deezer_id not in seen_deezer_ids: seen_deezer_ids.add(t.deezer_id) new_track_ids.append(t.deezer_id) # 4. Diff existing_track_ids = [ t["id"] for t in deezer_playlist.get("tracks", {}).get("data", []) ] to_remove = [tid for tid in existing_track_ids if tid not in new_track_ids] to_add = [tid for tid in new_track_ids if tid not in existing_track_ids] logger.debug( "Deezer: %d to remove, %d to add, %d to order", len(to_remove), len(to_add), len(new_track_ids), ) # 5. Remove then add (client handles batching internally) if to_remove: await deezer_client.delete_playlist_tracks( access_token, playlist_id, to_remove ) if to_add: await deezer_client.add_tracks(access_token, playlist_id, to_add) # 6. Set order (full track list) if new_track_ids: await deezer_client.order_playlist_tracks( access_token, playlist_id, new_track_ids ) # 7. Update metadata metadata_changed = False if ( sync.title_copy_mode != FieldCopyMode.NoUpdate or sync.description_copy_mode != FieldCopyMode.NoUpdate ): new_title, new_description = await self._update_playlist_info( generic_playlist, deezer_playlist, sync, access_token ) sync.to_playlist_title = new_title metadata_changed = True counts = SyncCounts( added_tracks=len(to_add), deleted_duplicates=0, deleted_tracks=len(to_remove), ) result = PlaylistSynchronizationResult.from_success( counts, len(generic_playlist.tracks) ) if metadata_changed and not result.made_changes: result.made_changes = True return result async def _set_deezer_track_ids( self, generic_playlist: GenericPlaylist, access_token: str, session: AsyncSession, ) -> None: """Resolve ISRC → Deezer track ID for all source tracks. Uses tblPlaylistSynchronizationTrack as a cache. Cache entries older than 7 days are treated as misses (to catch track ID changes), unless the playlist owner is in _BIG_STATIC_PLAYLIST_USERS. Mirrors: DeezerSynchronizer.SetDeezerTrackIds() + GetDeezerTrackMappingAsync() """ isrcs = [t.isrc for t in generic_playlist.tracks if t.isrc] if not isrcs: return cached = await isrc_cache_service.get_synchronized_tracks( isrcs, int(ServiceType.Deezer), session ) is_big_static = (generic_playlist.user or "").lower() in [ u.lower() for u in _BIG_STATIC_PLAYLIST_USERS ] cutoff = datetime.utcnow() - timedelta(days=_ISRC_CACHE_MAX_AGE_DAYS) if is_big_static: valid_cached = cached else: valid_cached = [c for c in cached if c.match_date >= cutoff] cached_map = {c.isrc.upper(): int(c.track_id) for c in valid_cached} # Concurrent ISRC lookups for cache misses (up to 10 in parallel, # mirrors .NET ForEachAsync(10)). # DB writes are done sequentially after all HTTP calls complete — AsyncSession # does not support concurrent operations; calling flush() from multiple # coroutines simultaneously raises "Session is already flushing". sem = asyncio.Semaphore(10) miss_isrcs = [isrc for isrc in isrcs if isrc.upper() not in cached_map] async def lookup(isrc: str) -> tuple[str, int | None]: async with sem: track = await deezer_client.get_track_by_isrc(access_token, isrc) return isrc, track["id"] if track and track.get("id") else None if miss_isrcs: logger.debug("Deezer ISRC lookup for %d cache misses", len(miss_isrcs)) lookup_results = await asyncio.gather( *[lookup(isrc) for isrc in miss_isrcs] ) for isrc, track_id in lookup_results: if track_id is not None: await isrc_cache_service.save_synchronized_track( isrc, int(ServiceType.Deezer), str(track_id), session ) cached_map[isrc.upper()] = track_id for track in generic_playlist.tracks: if track.isrc: track.deezer_id = cached_map.get(track.isrc.upper()) async def _update_playlist_info( self, generic_playlist: GenericPlaylist, deezer_playlist: dict, sync: PlaylistSynchronization, access_token: str, ) -> tuple[str, str]: """Update Deezer playlist title and/or description based on copy mode. Returns the (title, description) that were sent to Deezer. Mirrors: DeezerSynchronizer.UpdatePlaylistInfo() """ title = deezer_playlist.get("title", "") description = deezer_playlist.get("description", "") if sync.title_copy_mode == FieldCopyMode.UseSetting: title = sync.title or "" elif sync.title_copy_mode == FieldCopyMode.CopySource: title = generic_playlist.name if sync.description_copy_mode == FieldCopyMode.UseSetting: description = sync.description or "" elif sync.description_copy_mode == FieldCopyMode.CopySource: description = generic_playlist.description or "" logger.info( "Updating Deezer playlist %s metadata: title=%r, description=%r", deezer_playlist["id"], title, description, ) await deezer_client.set_playlist_info( access_token, deezer_playlist["id"], title, description, deezer_playlist.get("public", False), deezer_playlist.get("collaborative", False), ) return title, description