from __future__ import annotations from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Generic, Iterable, Mapping, Optional, Sequence, Type, TypeVar, cast from src import config from src.api_client.dsp import BaseDSPApiClient from src.api_client.errors import DSPApiError from src.data_info import PlaylistInfo, TrackInfo from src.enums import ServiceType from src.logger import BoundLogger from src.playlist_sync.services import TrackCacheService from src.utils import get_authorized_api_client from ..errors import SynchronizerError, VendorSpecificSynchronizerError from ..result import SyncResult if TYPE_CHECKING: from src.service_account.models import ServiceAccount __all__ = ["BaseSynchronizer"] T = TypeVar("T", bound=BaseDSPApiClient) class BaseSynchronizer(Generic[T], ABC): service_type: Optional[ServiceType] = None vendor_specific_sync_error_cls: Type[VendorSpecificSynchronizerError] def __init__(self, service_account: "ServiceAccount", *, logger: BoundLogger): self.service_account = service_account self._logger = logger self._db_track_service = TrackCacheService() self.api_client: T = self._get_api_client() def _get_api_client(self) -> T: api_client = get_authorized_api_client(self.service_account, self._logger) if api_client is None: raise SynchronizerError(f"Can't get API client for service account with id '{self.service_account.id}'") return cast(T, api_client) def _get_tracks_from_cache(self, tracks: Iterable[TrackInfo]) -> Mapping[str, str]: """ Getting tracks from cache :param tracks: tracks to lookup :return: Mapping with track ISRC as a key and relevant ID as a value """ if self.service_account.service_type is None: raise SynchronizerError(f"No service type for service account with id {self.service_account.id}") isrcs = [track.isrc for track in tracks if track.isrc is not None] mapping = {} for track_from_cache in self._db_track_service.get_tracks(self.service_account.service_type, isrcs): if track_from_cache.isrc is None: continue mapping[track_from_cache.isrc] = track_from_cache.track_id return mapping def _insert_tracks_to_cache(self, tracks: Mapping[str, str]): if self.service_account.service_type is None: raise SynchronizerError(f"No service type for service account with id {self.service_account.id}") self._db_track_service.upsert_tracks(self.service_account.service_type, tracks) @staticmethod def _is_info_update_needed(playlist_info: PlaylistInfo, update: Mapping[str, str | None]) -> bool: for key, value in update.items(): if getattr(playlist_info, key) != value: return True return False @abstractmethod def _get_tracks_from_dsp(self, tracks: Iterable[TrackInfo]) -> Mapping[TrackInfo, str]: """ Getting tracks from DSP :param tracks: tracks to lookup :return: Mapping with track as a key and relevant target id as a value """ pass def _get_source_track_ids( self, tracks: Iterable[TrackInfo], isrc_map: Mapping[str, str], id_map: Mapping[str, str] ) -> Iterable[str]: """ Get target DSP ids for source playlist tracks :param tracks: tracks to lookup :param isrc_map: track ISRC to target DSP id map :param id_map: source DSP id to target DSP id map :return: Iterable of target DSP ids """ for track in tracks: if track.isrc in isrc_map and track.isrc is not None: yield isrc_map[track.isrc] elif track.id in id_map: yield id_map[track.id] else: if config.SKIP_NOT_FOUND_TRACKS: self._logger.warning(f"Can't find track {track}") else: raise SynchronizerError(f"Can't find track {track}") @abstractmethod def _get_target_track_ids(self, playlist_id: str) -> Iterable[str]: pass @abstractmethod def _synchronize_tracks( self, playlist_id: str, source_track_ids: Sequence[str], target_track_ids: Sequence[str] ) -> tuple[int, int]: pass @abstractmethod def _update_playlist_info(self, playlist_id: str, old_info: PlaylistInfo, new_info: Mapping[str, str | None]): pass @abstractmethod def get_playlist_info(self, playlist_id: str) -> PlaylistInfo: pass @staticmethod def skip_existing_tracks(source_tracks: Sequence[TrackInfo], target_track_ids: Iterable[str]): return source_tracks def synchronize_tracks(self, target_playlist_id: str, source_tracks: Sequence[TrackInfo]) -> SyncResult: if not source_tracks: return SyncResult() try: target_track_ids = list(self._get_target_track_ids(target_playlist_id)) source_tracks = self.skip_existing_tracks(source_tracks, target_track_ids) if not source_tracks: return SyncResult() tracks_from_cache = self._get_tracks_from_cache(source_tracks) missing_tracks = [i for i in source_tracks if i.isrc not in tracks_from_cache] tracks_from_dsp = self._get_tracks_from_dsp(missing_tracks) if missing_tracks else {} source_id_to_target_id_map = {} isrc_to_target_id_map = {} for track, target_id in tracks_from_dsp.items(): source_id_to_target_id_map[track.id] = target_id if track.isrc is not None: isrc_to_target_id_map[track.isrc] = target_id if isrc_to_target_id_map: self._insert_tracks_to_cache(isrc_to_target_id_map) source_track_ids = self._get_source_track_ids(source_tracks, tracks_from_cache, source_id_to_target_id_map) inserted, deleted = self._synchronize_tracks(target_playlist_id, list(source_track_ids), target_track_ids) except DSPApiError as e: raise self.__class__.vendor_specific_sync_error_cls(e.response.text) from e return SyncResult( source_track_count=len(source_tracks), inserted_track_count=inserted, deleted_track_count=deleted ) def synchronize_info( self, target_playlist_id: str, info: Mapping[str, str | None], old_info: PlaylistInfo | None = None ) -> bool: if not info: return False try: if not old_info: old_info = self.get_playlist_info(target_playlist_id) if not self._is_info_update_needed(old_info, info): return False self._update_playlist_info(target_playlist_id, old_info, info) except DSPApiError as e: raise self.__class__.vendor_specific_sync_error_cls(e.response.text) from e return True def check_target_playlist(self, service_account: ServiceAccount, playlist_id: str) -> bool: return True