import glob import hashlib import itertools import json import logging import os import re import shutil import threading from collections import defaultdict from concurrent.futures import Future, ThreadPoolExecutor from datetime import datetime from enum import Enum, EnumMeta from functools import lru_cache from pathlib import Path from time import sleep from typing import ( Any, Callable, DefaultDict, Dict, Generic, Iterable, List, Optional, Set, Tuple, TypeVar, Union, ) import requests from fuzzywuzzy import fuzz try: import gi gi.require_version("NM", "1.0") from gi.repository import NM networkmanager_imported = True except Exception: # I really don't care what kind of exception it is, all that matters is the # import failed for some reason. logging.warning( "Unable to import NM from GLib. Detection of SSID will be disabled." ) networkmanager_imported = False from .config import AppConfiguration from .server import Server from .server.api_object import APIObject from .server.api_objects import ( AlbumID3, AlbumWithSongsID3, Artist, ArtistID3, ArtistInfo2, ArtistWithAlbumsID3, Child, Directory, Genre, Playlist, PlaylistWithSongs, ) class Singleton(type): """ Metaclass for :class:`CacheManager` so that it can be used like a singleton. """ def __getattr__(cls, name: str) -> Any: if not CacheManager._instance: return None # If the cache has a function to do the thing we want, use it. If # not, then go directly to the server (this is useful for things # that just send data to the server.) if hasattr(CacheManager._instance, name): return getattr(CacheManager._instance, name) else: return getattr(CacheManager._instance.server, name) return None class SongCacheStatus(Enum): NOT_CACHED = 0 CACHED = 1 PERMANENTLY_CACHED = 2 DOWNLOADING = 3 @lru_cache(maxsize=8192) def similarity_ratio(query: str, string: str) -> int: """ Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and the given ``string``. This ends up being called quite a lot, so the result is cached in an LRU cache using :class:`functools.lru_cache`. :param query: the query string :param string: the string to compare to the query string """ return fuzz.partial_ratio(query.lower(), string.lower()) S = TypeVar("S") class SearchResult: """ An object representing the aggregate results of a search which can include both server and local results. """ _artist: Set[ArtistID3] = set() _album: Set[AlbumID3] = set() _song: Set[Child] = set() _playlist: Set[Playlist] = set() def __init__(self, query: str): self.query = query def add_results(self, result_type: str, results: Iterable): """Adds the ``results`` to the ``_result_type`` set.""" if results is None: return member = f"_{result_type}" if getattr(self, member) is None: setattr(self, member, set()) setattr( self, member, getattr(getattr(self, member, set()), "union")(set(results)), ) def _to_result(self, it: Iterable[S], transform: Callable[[S], str],) -> List[S]: all_results = sorted( ((similarity_ratio(self.query, transform(x)), x) for x in it), key=lambda rx: rx[0], reverse=True, ) result: List[S] = [] for ratio, x in all_results: if ratio > 60 and len(result) < 20: result.append(x) else: # No use going on, all the rest are less. break return result @property def artist(self) -> Optional[List[ArtistID3]]: if self._artist is None: return None return self._to_result(self._artist, lambda a: a.name) @property def album(self) -> Optional[List[AlbumID3]]: if self._album is None: return None return self._to_result(self._album, lambda a: f"{a.name} - {a.artist}") @property def song(self) -> Optional[List[Child]]: if self._song is None: return None return self._to_result(self._song, lambda s: f"{s.title} - {s.artist}") @property def playlist(self) -> Optional[List[Playlist]]: if self._playlist is None: return None return self._to_result(self._playlist, lambda p: p.name) T = TypeVar("T") class CacheManager(metaclass=Singleton): """ Handles everything related to caching metadata and song files. """ executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50) should_exit: bool = False class Result(Generic[T]): # This needs to accept some way of: # 1. getting data from the server to fulfill the request # 2. coercing the data to the schema of the cachedb # 3. queries for retriving the data from the cachedb # All results should be retrieved using select statements from the DB """ A result from a CacheManager function. This is effectively a wrapper around a 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 @staticmethod def from_data(data: T) -> "CacheManager.Result[T]": result: "CacheManager.Result[T]" = CacheManager.Result() result.data = data return result @staticmethod def from_server( download_fn: Callable[[], T], before_download: Callable[[], Any] = None, after_download: Callable[[T], Any] = None, on_cancel: Callable[[], Any] = None, ) -> "CacheManager.Result[T]": result: "CacheManager.Result[T]" = CacheManager.Result() def future_fn() -> T: if before_download: before_download() return download_fn() result.future = CacheManager.executor.submit(future_fn) result.on_cancel = on_cancel if after_download is not None: result.future.add_done_callback( lambda f: after_download and after_download(f.result()) ) return result 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( "CacheManager.Result did not have either a data or 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.on_cancel is not None: self.on_cancel() if self.future is not None: return self.future.cancel() return True @property def is_future(self) -> bool: return self.future is not None @staticmethod def ready() -> bool: return CacheManager._instance is not None @staticmethod def shutdown(): logging.info("CacheManager shutdown start") CacheManager.should_exit = True CacheManager.executor.shutdown() CacheManager._instance.save_cache_info() logging.info("CacheManager shutdown complete") class CacheEncoder(json.JSONEncoder): def default(self, obj: Any) -> Optional[Union[int, List, Dict]]: """ Encodes Python objects to JSON. - ``datetime`` objects are converted to UNIX timestamps (``int``) - ``set`` objects are converted to ``list`` objects - ``APIObject`` objects are recursively encoded - ``EnumMeta`` objects are ignored - everything else is encoded using the default encoder """ if type(obj) == datetime: return int(obj.timestamp() * 1000) elif type(obj) == set: return list(obj) elif isinstance(obj, APIObject): return {k: v for k, v in obj.__dict__.items() if v is not None} elif isinstance(obj, EnumMeta): return None return json.JSONEncoder.default(self, obj) class __CacheManagerInternal: # Thread lock for preventing threads from overriding the state while # it's being saved. cache_lock = threading.Lock() cache: DefaultDict[str, Any] = defaultdict(dict) permanently_cached_paths: Set[str] = set() # The server instance. server: Server # TODO (#56): need to split out the song downloads and make them higher # priority I think. Maybe even need to just make this a priority queue. download_set_lock = threading.Lock() current_downloads: Set[str] = set() def __init__(self, app_config: AppConfiguration): self.app_config = app_config assert self.app_config.server is not None self.app_config.server # If connected to the "Local Network SSID", use the "Local Network # Address" instead of the "Server Address". hostname = self.app_config.server.server_address if self.app_config.server.local_network_ssid in self.current_ssids: hostname = self.app_config.server.local_network_address self.server = Server( name=self.app_config.server.name, hostname=hostname, username=self.app_config.server.username, password=self.app_config.server.password, disable_cert_verify=self.app_config.server.disable_cert_verify, ) self.download_limiter_semaphore = threading.Semaphore( self.app_config.concurrent_download_limit ) self.load_cache_info() @property def current_ssids(self) -> Set[str]: if not networkmanager_imported: return set() self.networkmanager_client = NM.Client.new() self.nmclient_initialized = False self._current_ssids: Set[str] = set() if not self.nmclient_initialized: # Only look at the active WiFi connections. for ac in self.networkmanager_client.get_active_connections(): if ac.get_connection_type() != "802-11-wireless": continue devs = ac.get_devices() if len(devs) != 1: continue if devs[0].get_device_type() != NM.DeviceType.WIFI: continue self._current_ssids.add(ac.get_id()) return self._current_ssids def load_cache_info(self): cache_meta_file = self.calculate_abs_path(".cache_meta") meta_json = {} if cache_meta_file.exists(): with open(cache_meta_file, "r") as f: try: meta_json = json.load(f) except json.decoder.JSONDecodeError: # Just continue with the default meta_json. logging.warning("Unable to load cache", stack_info=True) cache_version = meta_json.get("version", 0) if cache_version < 1: logging.info("Migrating cache to version 1.") cover_art_re = re.compile(r"(\d+)_(\d+)") abs_path = self.calculate_abs_path("cover_art/") abs_path.mkdir(parents=True, exist_ok=True) for cover_art_file in abs_path.iterdir(): match = cover_art_re.match(cover_art_file.name) if match: art_id, dimensions = map(int, match.groups()) if dimensions == 1000: no_dimens = cover_art_file.parent.joinpath("{art_id}") logging.debug(f"Moving {cover_art_file} to {no_dimens}") shutil.move(cover_art_file, no_dimens) else: logging.debug(f"Deleting {cover_art_file}") cover_art_file.unlink() self.cache["version"] = 1 cache_configs = [ # Playlists ("playlists", Playlist, list), ("playlist_details", PlaylistWithSongs, dict), ("genres", Genre, list), ("song_details", Child, dict), # Non-ID3 caches ("music_directories", Directory, dict), ("indexes", Artist, list), # ID3 caches ("albums", AlbumWithSongsID3, "dict-list"), ("album_details", AlbumWithSongsID3, dict), ("artists", ArtistID3, list), ("artist_details", ArtistWithAlbumsID3, dict), ("artist_infos", ArtistInfo2, dict), ] for name, type_name, default in cache_configs: if default == list: self.cache[name] = [ type_name.from_json(x) for x in meta_json.get(name) or [] ] elif default == dict: self.cache[name] = { id: type_name.from_json(x) for id, x in (meta_json.get(name) or {}).items() } elif default == "dict-list": self.cache[name] = { n: [type_name.from_json(x) for x in xs] for n, xs in (meta_json.get(name) or {}).items() } def save_cache_info(self): os.makedirs(self.app_config.cache_location, exist_ok=True) cache_meta_file = self.calculate_abs_path(".cache_meta") os.makedirs(os.path.dirname(cache_meta_file), exist_ok=True) with open(cache_meta_file, "w+") as f, self.cache_lock: f.write(json.dumps(self.cache, indent=2, cls=CacheManager.CacheEncoder)) def save_file(self, absolute_path: Path, data: bytes): # Make the necessary directories and write to file. os.makedirs(absolute_path.parent, exist_ok=True) with open(absolute_path, "wb+") as f: f.write(data) def calculate_abs_path(self, *relative_paths) -> Path: assert self.app_config.server is not None return Path(self.app_config.cache_location).joinpath( self.app_config.server.strhash(), *relative_paths ) def calculate_download_path(self, *relative_paths) -> Path: """ Determine where to temporarily put the file as it is downloading. """ assert self.app_config.server is not None xdg_cache_home = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser( "~/.cache" ) return Path(xdg_cache_home).joinpath( "sublime-music", self.app_config.server.strhash(), *relative_paths, ) def return_cached_or_download( self, relative_path: Union[Path, str], download_fn: Callable[[], bytes], before_download: Callable[[], None] = lambda: None, force: bool = False, allow_download: bool = True, ) -> "CacheManager.Result[str]": abs_path = self.calculate_abs_path(relative_path) abs_path_str = str(abs_path) download_path = self.calculate_download_path(relative_path) if abs_path.exists() and not force: return CacheManager.Result.from_data(abs_path_str) if not allow_download: return CacheManager.Result.from_data("") def do_download() -> str: resource_downloading = False with self.download_set_lock: if abs_path_str in self.current_downloads: resource_downloading = True self.current_downloads.add(abs_path_str) if resource_downloading: logging.info(f"{abs_path} already being downloaded.") # The resource is already being downloaded. Busy loop until # it has completed. Then, just return the path to the # resource. while abs_path_str in self.current_downloads: sleep(0.2) else: logging.info(f"{abs_path} not found. Downloading...") os.makedirs(download_path.parent, exist_ok=True) try: self.save_file(download_path, download_fn()) except requests.exceptions.ConnectionError: with self.download_set_lock: self.current_downloads.discard(abs_path_str) # Move the file to its cache download location. os.makedirs(abs_path.parent, exist_ok=True) if download_path.exists(): shutil.move(str(download_path), abs_path) logging.info(f"{abs_path} downloaded. Returning.") return abs_path_str def after_download(path: str): with self.download_set_lock: self.current_downloads.discard(path) return CacheManager.Result.from_server( do_download, before_download=before_download, after_download=after_download, ) @staticmethod def create_future(fn: Callable, *args) -> Future: """Creates a future on the CacheManager's executor.""" return CacheManager.executor.submit(fn, *args) def delete_cached_cover_art(self, id: int): relative_path = f"cover_art/*{id}*" abs_path = self.calculate_abs_path(relative_path) for path in glob.glob(str(abs_path)): Path(path).unlink() def get_playlists( self, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[List[Playlist]]": if self.cache.get("playlists") and not force: return CacheManager.Result.from_data(self.cache["playlists"] or []) def after_download(playlists: List[Playlist]): with self.cache_lock: self.cache["playlists"] = playlists self.save_cache_info() return CacheManager.Result.from_server( lambda: self.server.get_playlists().playlist or [], before_download=before_download, after_download=after_download, ) def invalidate_playlists_cache(self): if not self.cache.get("playlists"): return with self.cache_lock: self.cache["playlists"] = [] self.save_cache_info() def get_playlist( self, playlist_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[PlaylistWithSongs]": playlist_details = self.cache.get("playlist_details", {}) if playlist_id in playlist_details and not force: return CacheManager.Result.from_data(playlist_details[playlist_id]) def after_download(playlist: PlaylistWithSongs): with self.cache_lock: self.cache["playlist_details"][playlist_id] = playlist # Playlists have the song details, so save those too. for song in playlist.entry or []: self.cache["song_details"][song.id] = song self.save_cache_info() # Invalidate the cached photo if we are forcing a retrieval # from the server. if force: self.delete_cached_cover_art(playlist_id) return CacheManager.Result.from_server( lambda: self.server.get_playlist(playlist_id), before_download=before_download, after_download=after_download, ) def create_playlist(self, name: str) -> Future: def do_create_playlist(): self.server.create_playlist(name=name) return CacheManager.create_future(do_create_playlist) def update_playlist(self, playlist_id: int, *args, **kwargs) -> Future: def do_update_playlist(): self.server.update_playlist(playlist_id, *args, **kwargs) with self.cache_lock: del self.cache["playlist_details"][playlist_id] return CacheManager.create_future(do_update_playlist) def get_artists( self, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[List[ArtistID3]]": cache_name = "artists" if self.cache.get(cache_name) and not force: return CacheManager.Result.from_data(self.cache[cache_name]) def download_fn() -> List[ArtistID3]: artists: List[ArtistID3] = [] for index in self.server.get_artists().index: artists.extend(index.artist) return artists def after_download(artists: List[ArtistID3]): with self.cache_lock: self.cache[cache_name] = artists self.save_cache_info() return CacheManager.Result.from_server( download_fn, before_download=before_download, after_download=after_download, ) def get_artist( self, artist_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[ArtistWithAlbumsID3]": cache_name = "artist_details" if artist_id in self.cache.get(cache_name, {}) and not force: return CacheManager.Result.from_data(self.cache[cache_name][artist_id]) def after_download(artist: ArtistWithAlbumsID3): with self.cache_lock: self.cache[cache_name][artist_id] = artist self.save_cache_info() return CacheManager.Result.from_server( lambda: self.server.get_artist(artist_id), before_download=before_download, after_download=after_download, ) def get_indexes( self, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[List[Artist]]": cache_name = "indexes" if self.cache.get(cache_name) and not force: return CacheManager.Result.from_data(self.cache[cache_name]) def download_fn() -> List[Artist]: artists: List[Artist] = [] for index in self.server.get_indexes().index: artists.extend(index.artist) return artists def after_download(artists: List[Artist]): with self.cache_lock: self.cache[cache_name] = artists self.save_cache_info() return CacheManager.Result.from_server( download_fn, before_download=before_download, after_download=after_download, ) def get_music_directory( self, id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[Directory]": cache_name = "music_directories" if id in self.cache.get(cache_name, {}) and not force: return CacheManager.Result.from_data(self.cache[cache_name][id]) def after_download(directory: Directory): with self.cache_lock: self.cache[cache_name][id] = directory self.save_cache_info() return CacheManager.Result.from_server( lambda: self.server.get_music_directory(id), before_download=before_download, after_download=after_download, ) def get_artist_info( self, artist_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[ArtistInfo2]": cache_name = "artist_infos" if artist_id in self.cache.get(cache_name, {}) and not force: return CacheManager.Result.from_data(self.cache[cache_name][artist_id]) def after_download(artist_info: ArtistInfo2): if not artist_info: return with self.cache_lock: self.cache[cache_name][artist_id] = artist_info self.save_cache_info() return CacheManager.Result.from_server( lambda: (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()), before_download=before_download, after_download=after_download, ) def get_artist_artwork( self, artist: Union[Artist, ArtistID3], before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[str]": def do_get_artist_artwork( artist_info: ArtistInfo2, ) -> "CacheManager.Result[str]": lastfm_url = "".join(artist_info.largeImageUrl or []) is_placeholder = lastfm_url == "" is_placeholder |= lastfm_url.endswith( "2a96cbd8b46e442fc41c2b86b821562f.png" ) is_placeholder |= lastfm_url.endswith( "1024px-No_image_available.svg.png" ) # If it is the placeholder LastFM image, try and use the cover # art filename given by the server. if is_placeholder: if isinstance(artist, (ArtistWithAlbumsID3, ArtistID3)): if artist.coverArt: return CacheManager.get_cover_art_filename(artist.coverArt) elif ( isinstance(artist, ArtistWithAlbumsID3) and artist.album and len(artist.album) > 0 ): return CacheManager.get_cover_art_filename( artist.album[0].coverArt ) elif isinstance(artist, Directory) and len(artist.child) > 0: # Retrieve the first album's cover art return CacheManager.get_cover_art_filename( artist.child[0].coverArt ) if lastfm_url == "": return CacheManager.Result.from_data("") url_hash = hashlib.md5(lastfm_url.encode("utf-8")).hexdigest() return self.return_cached_or_download( f"cover_art/artist.{url_hash}", lambda: requests.get(lastfm_url).content, before_download=before_download, force=force, ) def download_fn(artist_info: CacheManager.Result[ArtistInfo2]) -> str: # In this case, artist_info is a future, so we have to wait for # its result before calculating. Then, immediately unwrap the # result() because we are already within a future. return do_get_artist_artwork(artist_info.result()).result() artist_info = CacheManager.get_artist_info(artist.id) if artist_info.is_future: return CacheManager.Result.from_server( lambda: download_fn(artist_info), before_download=before_download, ) else: return do_get_artist_artwork(artist_info.result()) def get_album_list( self, type_: str, before_download: Callable[[], None] = lambda: None, force: bool = False, # Look at documentation for get_album_list in server.py: **params, ) -> "CacheManager.Result[List[AlbumID3]]": cache_name = "albums" if len(self.cache.get(cache_name, {}).get(type_, [])) > 0 and not force: return CacheManager.Result.from_data(self.cache[cache_name][type_]) def do_get_album_list() -> List[AlbumID3]: def get_page(offset: int, page_size: int = 500,) -> List[AlbumID3]: return ( self.server.get_album_list2( type_, size=page_size, offset=offset, **params, ).album or [] ) page_size = 40 if type_ == "random" else 500 offset = 0 next_page = get_page(offset, page_size=page_size) albums = next_page # If it returns 500 things, then there's more leftover. while len(next_page) == 500: next_page = get_page(offset) albums.extend(next_page) offset += 500 return albums def after_download(albums: List[AlbumID3]): with self.cache_lock: if not self.cache[cache_name].get(type_): self.cache[cache_name][type_] = [] self.cache[cache_name][type_] = albums self.save_cache_info() return CacheManager.Result.from_server( do_get_album_list, before_download=before_download, after_download=after_download, ) def get_album( self, album_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[AlbumWithSongsID3]": cache_name = "album_details" if album_id in self.cache.get(cache_name, {}) and not force: return CacheManager.Result.from_data(self.cache[cache_name][album_id]) def after_download(album: AlbumWithSongsID3): with self.cache_lock: self.cache[cache_name][album_id] = album # Albums have the song details as well, so save those too. for song in album.get("song", []): self.cache["song_details"][song.id] = song self.save_cache_info() return CacheManager.Result.from_server( lambda: self.server.get_album(album_id), before_download=before_download, after_download=after_download, ) def batch_delete_cached_songs( self, song_ids: List[int], on_song_delete: Callable[[], None], ) -> Future: def do_delete_cached_songs(): # Do the actual download. for f in map(CacheManager.get_song_details, song_ids): song: Child = f.result() relative_path = song.path abs_path = self.calculate_abs_path(relative_path) if abs_path.exists(): abs_path.unlink() on_song_delete() return CacheManager.create_future(do_delete_cached_songs) def batch_download_songs( self, song_ids: List[int], before_download: Callable[[], None], on_song_download_complete: Callable[[], None], ) -> Future: def do_download_song(song_id: int): try: # If a song download is already in the queue and then the # app is exited, this prevents the download. if CacheManager.should_exit: return # Do the actual download. Call .result() because we are # already inside of a future. song = CacheManager.get_song_details(song_id).result() self.return_cached_or_download( song.path, lambda: self.server.download(song.id), before_download=before_download, ).result() on_song_download_complete() finally: # Release the semaphore lock. This will allow the next song # in the queue to be downloaded. I'm doing this in the # finally block so that it always runs, regardless of # whether an exception is thrown or the function returns. self.download_limiter_semaphore.release() def do_batch_download_songs(): for song_id in song_ids: # Only allow a certain number of songs ot be downloaded # simultaneously. self.download_limiter_semaphore.acquire() # Prevents further songs from being downloaded. if CacheManager.should_exit: break CacheManager.create_future(do_download_song, song_id) return CacheManager.create_future(do_batch_download_songs) def get_cover_art_filename( self, id: str, before_download: Callable[[], None] = lambda: None, force: bool = False, allow_download: bool = True, ) -> "CacheManager.Result[str]": if id is None: default_art_path = "ui/images/default-album-art.png" return CacheManager.Result.from_data( str(Path(__file__).parent.joinpath(default_art_path)) ) return self.return_cached_or_download( f"cover_art/{id}", lambda: self.server.get_cover_art(id), before_download=before_download, force=force, allow_download=allow_download, ) def get_song_details( self, song_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[Child]": cache_name = "song_details" if self.cache[cache_name].get(song_id) and not force: return CacheManager.Result.from_data(self.cache[cache_name][song_id]) def after_download(song_details: Child): with self.cache_lock: self.cache[cache_name][song_id] = song_details self.save_cache_info() return CacheManager.Result.from_server( lambda: self.server.get_song(song_id), before_download=before_download, after_download=after_download, ) def get_play_queue(self) -> Future: return CacheManager.create_future(self.server.get_play_queue) def save_play_queue( self, play_queue: List[str], current: str, position: float, ): CacheManager.create_future( self.server.save_play_queue, play_queue, current, position ) def scrobble(self, song_id: int) -> Future: def do_scrobble(): self.server.scrobble(song_id) return CacheManager.create_future(do_scrobble) def get_song_filename_or_stream( self, song: Child, format: str = None, force_stream: bool = False, ) -> Tuple[str, bool]: abs_path = self.calculate_abs_path(song.path) if abs_path.exists() and not force_stream: return (str(abs_path), False) return (self.server.get_stream_url(song.id, format=format), True) def get_genres( self, before_download: Callable[[], None] = lambda: None, force: bool = False, ) -> "CacheManager.Result[List[Genre]]": if self.cache.get("genres") and not force: return CacheManager.Result.from_data(self.cache["genres"]) def after_download(genres: List[Genre]): with self.cache_lock: self.cache["genres"] = genres self.save_cache_info() return CacheManager.Result.from_server( lambda: self.server.get_genres().genre, before_download=before_download, after_download=after_download, ) def search( self, query: str, search_callback: Callable[[SearchResult, bool], None], before_download: Callable[[], None] = lambda: None, ) -> "CacheManager.Result": if query == "": search_callback(SearchResult(""), True) return CacheManager.Result.from_data(None) before_download() # Keep track of if the result is cancelled and if it is, then don't # do anything with any results. cancelled = False # This future actually does the search and calls the # search_callback when each of the futures completes. def do_search(): # Sleep for a little while before returning the local results. # They are less expensive to retrieve (but they still incur # some overhead due to the GTK UI main loop queue). sleep(0.2) if cancelled: return # Local Results search_result = SearchResult(query) search_result.add_results( "album", itertools.chain(*self.cache["albums"].values()) ) search_result.add_results("artist", self.cache["artists"]) search_result.add_results("song", self.cache["song_details"].values()) search_result.add_results("playlist", self.cache["playlists"]) search_callback(search_result, False) # Wait longer to see if the user types anything else so we # don't peg the server with tons of requests. sleep(0.2) if cancelled: return # Server Results search_fn = self.server.search3 try: # Attempt to add the server search results to the # SearchResult. If it fails, that's fine, we will use the # finally to always return a final SearchResult to the UI. server_result = search_fn(query) search_result.add_results("album", server_result.album) search_result.add_results("artist", server_result.artist) search_result.add_results("song", server_result.song) except Exception: # We really don't care about what the exception was (could # be connection error, could be invalid JSON, etc.) because # we will always have returned local results. return finally: search_callback(search_result, True) # When the future is cancelled (this will happen if a new search is # created). def on_cancel(): nonlocal cancelled cancelled = True return CacheManager.Result.from_server(do_search, on_cancel=on_cancel) def get_cached_status(self, song: Child) -> SongCacheStatus: cache_path = self.calculate_abs_path(song.path) if cache_path.exists(): if cache_path in self.permanently_cached_paths: return SongCacheStatus.PERMANENTLY_CACHED else: return SongCacheStatus.CACHED elif str(cache_path) in self.current_downloads: return SongCacheStatus.DOWNLOADING else: return SongCacheStatus.NOT_CACHED _instance: Optional[__CacheManagerInternal] = None def __init__(self): raise Exception("Do not instantiate the CacheManager.") @staticmethod def reset(app_config: AppConfiguration): CacheManager._instance = CacheManager.__CacheManagerInternal(app_config) similarity_ratio.cache_clear()