"""Spotify-to-Spotify playlist synchronizer. Mirrors Sony.Filtr.PlaylistSynchronization.Synchronizer.SpotifySynchronizer from .NET. Algorithm: 1. Fetch target Spotify playlist (all tracks). 2. Look up per-account Spotify client credentials (tblSpotifyApiKeys, fallback to env). 3. Refresh access token if expired; persist new token to tblServiceAccount. 4. Build authenticated Spotipy client. 5. Remove local tracks from the target (they cannot be re-added). 6. Compute the minimal insert/delete edit script that turns the target track sequence into the source track sequence, using difflib.SequenceMatcher. Source track multiplicity is preserved — if the source has the same URI 3 times, the target ends up with 3 occurrences. 7. Apply inserts (positional) and deletes (by position) in opcode order, keeping a running offset so subsequent opcode positions line up against the mutated target. 8. Update playlist title/description if TitleCopyMode != NoUpdate. 9. Return SyncCounts. """ import logging import time from collections.abc import Iterator from difflib import SequenceMatcher from typing import Optional import spotipy from sqlalchemy.ext.asyncio import AsyncSession from playlist_sync.clients import spotify_client from playlist_sync.models.enums import FieldCopyMode, 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.sync_service import ServiceAccountService from .base import BaseSynchronizer logger = logging.getLogger("playlist_sync.spotify_synchronizer") def _chunk(lst: list, size: int) -> Iterator[list]: for i in range(0, len(lst), size): yield lst[i : i + size] def compare_sequences( source: list[str], target: list[str] ) -> Iterator[tuple[str, int, int, int, int, int]]: """Yield non-equal SequenceMatcher opcodes plus a running target-side offset. Each yielded tuple is (action, t1, t2, s1, s2, offset) where: - action is "insert", "delete", or "replace" - target[t1:t2] / source[s1:s2] are the affected slices - offset is the cumulative shift applied to target indices so far The caller adds `offset` to t1/t2 when locating positions in the *mutated* target list (after previous inserts/deletes have been applied). Mirrors apollo-playlists-sync/src/synchronizer/utils.py:compare_sequences. """ sm = SequenceMatcher(a=target, b=source, autojunk=False) offset = 0 for action, t1, t2, s1, s2 in sm.get_opcodes(): if action == "equal": continue yield action, t1, t2, s1, s2, offset if action == "insert": offset += s2 - s1 elif action == "delete": offset -= t2 - t1 elif action == "replace": offset += (s2 - s1) - (t2 - t1) def _get_local_positions(items: list[dict]) -> list[int]: """Return positions of local tracks (which Spotify won't let us re-add).""" return [idx for idx, item in enumerate(items) if item.get("is_local", False)] class SpotifySynchronizer(BaseSynchronizer): """Spotify-to-Spotify playlist synchronizer.""" async def _copy_internal( self, generic_playlist: GenericPlaylist, sync: PlaylistSynchronization, service_account: ServiceAccount, session: AsyncSession, ) -> PlaylistSynchronizationResult: logger.debug( "SpotifySynchronizer: copying '%s' to Spotify playlist %s", generic_playlist.name, sync.to_playlist_id, ) # 2. Get per-account Spotify credentials account_service = ServiceAccountService(session) if service_account.id is not None: key_pair = await account_service.get_spotify_api_keys(service_account.id) else: key_pair = None client_id = key_pair[0] if key_pair else None client_secret = key_pair[1] if key_pair else None # 3. Refresh access token if expired (with 60-second buffer), persist to DB. # # Format note: the .NET app stores AccessTokenExpiry as a duration in seconds # (Spotify's expires_in = 3600) relative to UpdatedDate. Our Python code writes # Unix timestamps. We detect which format is in use by magnitude: any real Unix # timestamp for 2024+ is > 1_700_000_000, while a duration like 3600 is tiny. access_token = service_account.access_token if service_account.refresh_token: raw_expiry = service_account.access_token_expiry if raw_expiry is not None and raw_expiry < 86_400: # .NET format: duration in seconds from UpdatedDate base = service_account.updated_date actual_expiry = (int(base.timestamp()) + raw_expiry) if base else 0 else: # Python format: Unix timestamp (or None → always refresh) actual_expiry = raw_expiry if raw_expiry is not None else 0 if actual_expiry < int(time.time()) + 60: logger.info( "Access token for account %s is expired or close to expiry.", service_account.id, ) token_data = spotify_client.refresh_access_token( service_account.refresh_token, client_id, client_secret ) if token_data and token_data.get("access_token"): access_token = token_data["access_token"] new_expiry = int(time.time()) + token_data.get("expires_in", 3600) if service_account.id is not None: await account_service.update_tokens( service_account.id, access_token, new_expiry ) logger.info( "Token refreshed and persisted for account %s.", service_account.id, ) else: logger.warning( "Token refresh failed for account %s; using existing token.", service_account.id, ) if access_token is None: return PlaylistSynchronizationResult.from_error( SyncError.Unauthorized, "Service account has no access token", ) # 4. Build authenticated client sp = spotify_client.get_authenticated_client( access_token=access_token, client_id=client_id, client_secret=client_secret, ) # 1. Fetch the target Spotify playlist using the authenticated client # so that private playlists can be read with the service-account token. target_playlist = spotify_client.get_playlist_with_all_tracks( sync.to_playlist_id, sp=sp ) if not target_playlist: return PlaylistSynchronizationResult.from_error( SyncError.NoTargetPlaylist, f"Could not find target playlist {sync.to_playlist_id}", ) target_items: list[dict] = target_playlist.get("tracks", {}).get("items", []) snapshot_id: Optional[str] = target_playlist.get("snapshot_id") target_images = target_playlist.get("images") or [] sync.to_playlist_title = target_playlist.get("name") sync.to_playlist_image = target_images[0].get("url") if target_images else None valid_target_items = [ item for item in target_items if item and item.get("track") ] # 5. Remove local tracks first — they can't be re-added once removed. local_positions = _get_local_positions(valid_target_items) if local_positions: logger.debug("Removing %d local tracks", len(local_positions)) snapshot_id = spotify_client.delete_playlist_tracks_by_position( sp, service_account.user_identifier, target_playlist["id"], local_positions, valid_target_items, snapshot_id, ) non_local_items = [ item for item in valid_target_items if not item.get("is_local", False) ] target_uris = [item["track"]["uri"] for item in non_local_items] source_uris = [t.spotify_uri for t in generic_playlist.tracks] # 6 + 7. Compute insert/delete edit script via SequenceMatcher and apply. # `mutated_uris` tracks the current state of the target so that subsequent # opcode positions are accurate after each modification. mutated_uris: list[str] = list(target_uris) added_count = 0 deleted_count = 0 for action, t1, t2, s1, s2, offset in compare_sequences( source_uris, target_uris ): t_from = t1 + offset t_to = t2 + offset if action == "insert": to_add = source_uris[s1:s2] responses = spotify_client.add_tracks_at_position( sp, service_account.user_identifier, target_playlist["id"], to_add, t_from, ) if responses: snapshot_id = responses[-1].get("snapshot_id", snapshot_id) mutated_uris[t_from:t_from] = to_add added_count += len(to_add) elif action == "delete": positions = list(range(t_from, t_to)) snapshot_id = spotify_client.delete_playlist_tracks_by_position( sp, service_account.user_identifier, target_playlist["id"], positions, [{"track": {"uri": u}} for u in mutated_uris], snapshot_id, ) del mutated_uris[t_from:t_to] deleted_count += t_to - t_from elif action == "replace": # Delete first, then insert at the same position. This keeps the # mutation order simple: positions for the delete are valid before # the insert shifts anything. positions = list(range(t_from, t_to)) snapshot_id = spotify_client.delete_playlist_tracks_by_position( sp, service_account.user_identifier, target_playlist["id"], positions, [{"track": {"uri": u}} for u in mutated_uris], snapshot_id, ) del mutated_uris[t_from:t_to] deleted_count += t_to - t_from to_add = source_uris[s1:s2] responses = spotify_client.add_tracks_at_position( sp, service_account.user_identifier, target_playlist["id"], to_add, t_from, ) if responses: snapshot_id = responses[-1].get("snapshot_id", snapshot_id) mutated_uris[t_from:t_from] = to_add added_count += len(to_add) # 8. Update title and/or description if ( sync.title_copy_mode != FieldCopyMode.NoUpdate or sync.description_copy_mode != FieldCopyMode.NoUpdate ): new_title = await _update_playlist_info( sp, target_playlist, sync, generic_playlist, service_account ) sync.to_playlist_title = new_title counts = SyncCounts( added_tracks=added_count, deleted_duplicates=len(local_positions), deleted_tracks=deleted_count, ) return PlaylistSynchronizationResult.from_success( counts, len(generic_playlist.tracks) ) async def _update_playlist_info( sp: spotipy.Spotify, target_playlist: dict, sync: PlaylistSynchronization, generic_playlist: GenericPlaylist, service_account: ServiceAccount, ) -> str: """Update Spotify playlist title and/or description based on copy mode. Returns the title that was sent to Spotify (used to update sync.to_playlist_title). Mirrors: SpotifySynchronizer title/description update logic. """ title: str = target_playlist.get("name") or "" description: Optional[str] = None if sync.title_copy_mode == FieldCopyMode.CopySource: title = generic_playlist.name elif sync.title_copy_mode == FieldCopyMode.UseSetting: title = sync.title or "" if sync.description_copy_mode == FieldCopyMode.CopySource: description = generic_playlist.description or "" elif sync.description_copy_mode == FieldCopyMode.UseSetting: description = sync.description or "" spotify_client.set_playlist_details( sp, service_account.user_identifier, target_playlist["id"], title, target_playlist.get("public"), description, ) return title