import logging from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import ( Any, Callable, Generic, Optional, Sequence, Set, Type, TypeVar, Union, ) from sublime.config import AppConfiguration from .adapter_base import Adapter, CacheMissError, CachingAdapter from .api_objects import Playlist, PlaylistDetails from .filesystem import FilesystemAdapter from .subsonic import SubsonicAdapter T = TypeVar("T") class Result(Generic[T]): """ A result from a :class:`AdapterManager` function. This is effectively a wrapper around a :class:`concurrent.futures.Future`, but it can also resolve immediately if the data already exists. """ _data: Optional[T] = None _future: Optional[Future] = None on_cancel: Optional[Callable[[], None]] = None def __init__(self, data_resolver: Union[T, Callable[[], T]]): if callable(data_resolver): def future_complete(f: Future): self._data = f.result() self._future = AdapterManager.executor.submit(data_resolver) self._future.add_done_callback(future_complete) else: self._data = data_resolver def result(self) -> T: if self._data is not None: return self._data if self._future is not None: return self._future.result() raise Exception("AdapterManager.Result had neither _data nor _future member!") def add_done_callback(self, fn: Callable, *args): if self._future is not None: self._future.add_done_callback(fn, *args) else: # Run the function immediately if it's not a future. fn(self, *args) def cancel(self) -> bool: if self._future is not None: return self._future.cancel() return True @property def data_is_available(self) -> bool: return self._data is not None class AdapterManager: available_adapters: Set[Any] = {FilesystemAdapter, SubsonicAdapter} executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50) is_shutting_down: bool = False @dataclass class _AdapterManagerInternal: ground_truth_adapter: Adapter caching_adapter: Optional[CachingAdapter] = None def shutdown(self): self.ground_truth_adapter.shutdown() if self.caching_adapter: self.caching_adapter.shutdown() _instance: Optional[_AdapterManagerInternal] = None @staticmethod def register_adapter(adapter_class: Type): if not issubclass(adapter_class, Adapter): raise TypeError("Attempting to register a class that is not an adapter.") AdapterManager.available_adapters.add(adapter_class) def __init__(self): """ This should not ever be called. You should only ever use the static methods on this class. """ raise Exception( "Do not instantiate the AdapterManager. " "Only use the static methods on the class." ) @staticmethod def shutdown(): logging.info("AdapterManager shutdown start") AdapterManager.is_shutting_down = True AdapterManager.executor.shutdown() if AdapterManager._instance: AdapterManager._instance.shutdown() logging.info("CacheManager shutdown complete") @staticmethod def reset(config: AppConfiguration): # First, shutdown the current one... if AdapterManager._instance: AdapterManager._instance.shutdown() # TODO: actually do stuff with the config to determine which adapters # to create, etc. assert config.server is not None source_data_dir = Path(config.cache_location, config.server.strhash()) source_data_dir.joinpath("g").mkdir(parents=True, exist_ok=True) source_data_dir.joinpath("c").mkdir(parents=True, exist_ok=True) ground_truth_adapter_type = SubsonicAdapter ground_truth_adapter = ground_truth_adapter_type( { key: getattr(config.server, key) for key in ground_truth_adapter_type.get_config_parameters() }, source_data_dir.joinpath("g"), ) caching_adapter_type = FilesystemAdapter caching_adapter = None if caching_adapter_type and ground_truth_adapter_type.can_be_cached: caching_adapter = caching_adapter_type( { key: getattr(config.server, key) for key in caching_adapter_type.get_config_parameters() }, source_data_dir.joinpath("c"), is_cache=True, ) AdapterManager._instance = AdapterManager._AdapterManagerInternal( ground_truth_adapter, caching_adapter=caching_adapter, ) @staticmethod def can_get_playlists() -> bool: # It only matters that the ground truth one can service the request. return ( AdapterManager._instance is not None and AdapterManager._instance.ground_truth_adapter.can_service_requests and AdapterManager._instance.ground_truth_adapter.can_get_playlists ) @staticmethod def get_playlists( before_download: Callable[[], None] = lambda: None, force: bool = False, # TODO: rename to use_ground_truth_adapter? ) -> Result[Sequence[Playlist]]: assert AdapterManager._instance if ( not force and AdapterManager._instance.caching_adapter and AdapterManager._instance.caching_adapter.can_service_requests and AdapterManager._instance.caching_adapter.can_get_playlists ): try: return Result(AdapterManager._instance.caching_adapter.get_playlists()) except CacheMissError: logging.debug(f'Cache Miss on {"get_playlists"}.') except Exception: logging.exception(f'Error on {"get_playlists"} retrieving from cache.') if ( AdapterManager._instance.ground_truth_adapter and not AdapterManager._instance.ground_truth_adapter.can_service_requests and not AdapterManager._instance.ground_truth_adapter.can_get_playlists ): raise Exception(f'No adapters can service {"get_playlists"} at the moment.') def future_fn() -> Sequence[Playlist]: assert AdapterManager._instance if before_download: before_download() return AdapterManager._instance.ground_truth_adapter.get_playlists() future: Result[Sequence[Playlist]] = Result(future_fn) if AdapterManager._instance.caching_adapter: def future_finished(f: Future): assert AdapterManager._instance assert AdapterManager._instance.caching_adapter AdapterManager._instance.caching_adapter.ingest_new_data( CachingAdapter.FunctionNames.GET_PLAYLISTS, (), f.result(), ) future.add_done_callback(future_finished) return future @staticmethod def can_get_playlist_details() -> bool: # It only matters that the ground truth one can service the request. return ( AdapterManager._instance is not None and AdapterManager._instance.ground_truth_adapter.can_service_requests and AdapterManager._instance.ground_truth_adapter.can_get_playlist_details ) @staticmethod def get_playlist_details( playlist_id: str, before_download: Callable[[], None] = lambda: None, force: bool = False, # TODO: rename to use_ground_truth_adapter? ) -> Result[PlaylistDetails]: assert AdapterManager._instance partial_playlist_data = None if ( not force and AdapterManager._instance.caching_adapter and AdapterManager._instance.caching_adapter.can_service_requests and AdapterManager._instance.caching_adapter.can_get_playlist_details ): try: return Result( AdapterManager._instance.caching_adapter.get_playlist_details( playlist_id ) ) except CacheMissError as e: partial_playlist_data = e.partial_data logging.debug(f'Cache Miss on {"get_playlist_details"}.') except Exception: logging.exception( f'Error on {"get_playlist_details"} retrieving from cache.' ) if ( AdapterManager._instance.ground_truth_adapter and not AdapterManager._instance.ground_truth_adapter.can_service_requests and not ( AdapterManager._instance.ground_truth_adapter.can_get_playlist_details ) ): if partial_playlist_data: # TODO do something here pass raise Exception( f'No adapters can service {"get_playlist_details"} at the moment.' ) def future_fn() -> PlaylistDetails: assert AdapterManager._instance if before_download: before_download() return AdapterManager._instance.ground_truth_adapter.get_playlist_details( playlist_id ) future: Result[PlaylistDetails] = Result(future_fn) if AdapterManager._instance.caching_adapter: def future_finished(f: Future): assert AdapterManager._instance assert AdapterManager._instance.caching_adapter AdapterManager._instance.caching_adapter.ingest_new_data( CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS, (playlist_id,), f.result(), ) future.add_done_callback(future_finished) return future