"""SoundCloud playlist synchronizer. Mirrors Sony.Filtr.PlaylistSynchronization.Synchronizer.SoundCloudSynchronizer from .NET. Algorithm: 1. Resolve to_playlist_id (URL or numeric ID) → fetch SoundCloud playlist. 2. For each source track: a. Search SoundCloud by ISRC (primary) or title. b. Prefer an exact ISRC match; fall back to the first result. 3. Replace the playlist track list with the resolved tracks. 4. Update playlist title/description according to TitleCopyMode/DescriptionCopyMode (max 100 chars for title, 4000 for description). 5. Return SyncCounts. """ import logging from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from playlist_sync.clients import soundcloud_client from playlist_sync.models.enums import FieldCopyMode, SyncError from playlist_sync.models.exceptions import PlaylistSynchronizationException 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 .base import BaseSynchronizer, warn_if_source_has_duplicates logger = logging.getLogger("playlist_sync.soundcloud_synchronizer") _MAX_TITLE_LEN = 100 _MAX_DESCRIPTION_LEN = 4000 class SoundCloudSynchronizer(BaseSynchronizer): """SoundCloud playlist synchronizer.""" async def _copy_internal( self, generic_playlist: GenericPlaylist, sync: PlaylistSynchronization, service_account: ServiceAccount, session: AsyncSession, ) -> PlaylistSynchronizationResult: access_token = service_account.access_token if access_token is None: return PlaylistSynchronizationResult.from_error( SyncError.Unauthorized, "Service account has no access token", ) to_id = sync.to_playlist_id logger.debug( "SoundCloudSynchronizer: copying '%s' to SoundCloud playlist %s", generic_playlist.name, to_id, ) warn_if_source_has_duplicates(generic_playlist.tracks, "SoundCloud", sync.id) # 1. Resolve playlist sc_playlist: Optional[dict] = None playlist_id: Optional[int] = None if to_id.startswith("https://soundcloud.com"): sc_playlist = await soundcloud_client.get_playlist_by_url( to_id, access_token ) if sc_playlist: playlist_id = sc_playlist.get("id") else: try: playlist_id = int(to_id) sc_playlist = await soundcloud_client.get_playlist_by_id( playlist_id, access_token ) except ValueError as exc: raise PlaylistSynchronizationException( f"Could not parse to_playlist_id for SoundCloud sync: '{to_id}'", SyncError.VendorSpecific, ) from exc if not sc_playlist or not playlist_id: raise PlaylistSynchronizationException( f"Could not find SoundCloud playlist: '{to_id}'", SyncError.NoTargetPlaylist, ) sync.to_playlist_title = sc_playlist.get("title") sync.to_playlist_image = sc_playlist.get("artwork_url") # 2. Resolve each source track → SoundCloud track object tracks_to_add = [] for track in generic_playlist.tracks: matched_tracks = await soundcloud_client.search_tracks( track.name, track.isrc, access_token ) # Prefer an exact ISRC match exact_match = None if track.isrc: exact_match = next( ( t for t in matched_tracks if (t.get("isrc") or "").upper() == track.isrc.upper() ), None, ) chosen = exact_match or (matched_tracks[0] if matched_tracks else None) if chosen: tracks_to_add.append({"id": chosen["id"]}) # 3. Replace playlist track list await soundcloud_client.add_tracks_to_playlist( playlist_id, tracks_to_add, access_token ) # 4. Update metadata await self._set_playlist_info( generic_playlist, sync, str(playlist_id), access_token ) counts = SyncCounts( added_tracks=len(tracks_to_add), deleted_duplicates=0, deleted_tracks=0, ) return PlaylistSynchronizationResult.from_success( counts, len(generic_playlist.tracks) ) async def _set_playlist_info( self, generic_playlist: GenericPlaylist, sync: PlaylistSynchronization, playlist_id: str, access_token: str, ) -> None: """Update SoundCloud playlist title and description. Respects char limits: title ≤ 100, description ≤ 4000. Mirrors: SoundCloudSynchronizer.SetPlaylistInfo() """ title: Optional[str] = None description: Optional[str] = None if sync.title_copy_mode != FieldCopyMode.NoUpdate: if sync.title_copy_mode == FieldCopyMode.UseSetting: raw = sync.title or "" else: # CopySource raw = generic_playlist.name or "" title = raw[:_MAX_TITLE_LEN] if sync.description_copy_mode != FieldCopyMode.NoUpdate: if sync.description_copy_mode == FieldCopyMode.UseSetting: raw = sync.description or "" else: # CopySource raw = generic_playlist.description or "" description = raw[:_MAX_DESCRIPTION_LEN] if title is not None or description is not None: await soundcloud_client.update_playlist_info( playlist_id, title, description, access_token )