Added initial sync for finishing server ping to avoid blocking the main thread

This commit is contained in:
Sumner Evans
2020-05-16 18:12:55 -06:00
parent 7f7c5a72d6
commit 914136b474
8 changed files with 147 additions and 51 deletions

View File

@@ -1,3 +1,18 @@
v0.9.3
======
* **Features**
* The Albums tab is now paginated.
* You can sort the Albums tab ascending or descending.
* This release has a ton of under-the-hood changes to make things more robust
and performant.
* The cache is now stored in a SQLite database.
* The cache is no longer reliant on Subsonic which will enable more backends
in the future.
v0.9.2
======

View File

@@ -153,13 +153,13 @@ Simulating Bad Network Conditions
One of the primary goals of this project is to be resilient to crappy network
conditions. If you have good internet, you can simulate bad internet with the
``SUBSONIC_ADAPTER_DEBUG_DELAY`` environment variable. This environment variable
should be two values, separated by a ``,``: the lower and upper limit for the
delay to add to each network request. The delay will be a random number between
the lower and upper bounds. For example, the following will run Sublime Music
and every request will have an additional 3-5 seconds of latency::
``REQUEST_DELAY`` environment variable. This environment variable should be two
values, separated by a ``,``: the lower and upper limit for the delay to add to
each network request. The delay will be a random number between the lower and
upper bounds. For example, the following will run Sublime Music and every
request will have an additional 3-5 seconds of latency::
SUBSONIC_ADAPTER_DEBUG_DELAY=3,5 sublime-music
REQUEST_DELAY=3,5 sublime-music
CI/CD Pipeline
--------------

View File

@@ -239,6 +239,10 @@ class Adapter(abc.ABC):
This function should be overridden by inheritors of :class:`Adapter` and should
be used to do whatever setup is required for the adapter.
This should do the bare minimum to get things set up, since this blocks the main
UI loop. If you need to do longer initialization, use the :class:`initial_sync`
function.
:param config: The adapter configuration. The keys of are the configuration
parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual value of
@@ -247,6 +251,14 @@ class Adapter(abc.ABC):
directory is guaranteed to exist.
"""
@abc.abstractmethod
def initial_sync(self):
"""
Perform any operations that are required to get the adapter functioning
properly. For example, this function can be used to wait for an initial ping to
come back from the server.
"""
@abc.abstractmethod
def shutdown(self):
"""

View File

@@ -20,6 +20,9 @@ from .. import (
)
KEYS = CachingAdapter.CachedDataKey
class FilesystemAdapter(CachingAdapter):
"""
Defines an adapter which retrieves its data from the local filesystem.
@@ -60,6 +63,10 @@ class FilesystemAdapter(CachingAdapter):
models.database.create_tables(models.ALL_TABLES)
self._migrate_db()
def initial_sync(self):
# TODO this is where scanning the fs should potentially happen?
pass
def shutdown(self):
logging.info("Shutdown complete")
@@ -71,24 +78,45 @@ class FilesystemAdapter(CachingAdapter):
# Usage and Availability Properties
# ==================================================================================
can_be_cached = False # Can't be cached (there's no need).
is_networked = False # Can't be cached (there's no need).
is_networked = False # Doesn't access the network.
can_service_requests = True # Can always be used to service requests.
# TODO make these dependent on cache state. Need to do this kinda efficiently
can_get_playlists = True
can_get_playlist_details = True
can_get_cover_art_uri = True
can_get_song_uri = True
can_get_song_details = True
can_get_artists = True
can_get_artist = True
can_get_albums = True
can_get_album = True
can_get_ignored_articles = True
can_get_directory = True
can_get_genres = True
can_search = True
def _can_get_key(self, cache_key: CachingAdapter.CachedDataKey) -> bool:
if not self.is_cache:
return True
# As long as there's something in the cache (even if it's not valid) it may be
# returned in a cache miss error.
query = models.CacheInfo.select().where(models.CacheInfo.cache_key == cache_key)
return query.count() > 0
@property
def can_get_playlists(self) -> bool:
return self._can_get_key(KEYS.PLAYLISTS)
@property
def can_get_playlist_details(self) -> bool:
return self._can_get_key(KEYS.PLAYLIST_DETAILS)
@property
def can_get_artists(self) -> bool:
return self._can_get_key(KEYS.ARTISTS)
@property
def can_get_genres(self) -> bool:
return self._can_get_key(KEYS.GENRES)
supported_schemes = ("file",)
supported_artist_query_types = {
AlbumSearchQuery.Type.RANDOM,
@@ -390,8 +418,6 @@ class FilesystemAdapter(CachingAdapter):
f"_do_ingest_new_data param={param} data_key={data_key} data={data}"
)
KEYS = CachingAdapter.CachedDataKey
def setattrs(obj: Any, data: Dict[str, Any]):
for k, v in data.items():
if v:

View File

@@ -1,5 +1,7 @@
import hashlib
import logging
import os
import random
import tempfile
import threading
from concurrent.futures import Future, ThreadPoolExecutor
@@ -17,6 +19,7 @@ from typing import (
Optional,
Sequence,
Set,
Tuple,
Type,
TypeVar,
Union,
@@ -47,6 +50,13 @@ from .api_objects import (
from .filesystem import FilesystemAdapter
from .subsonic import SubsonicAdapter
REQUEST_DELAY: Optional[Tuple[float, float]] = None
if delay_str := os.environ.get("REQUEST_DELAY"):
if "," in delay_str:
high, low = map(float, delay_str.split(","))
REQUEST_DELAY = (high, low)
else:
REQUEST_DELAY = (float(delay_str), float(delay_str))
T = TypeVar("T")
@@ -189,10 +199,14 @@ class AdapterManager:
this class.
"""
raise Exception(
"Do not instantiate the AdapterManager. "
"Only use the static methods on the class."
"Do not instantiate the AdapterManager. Only use the static methods on the class." # noqa: 512
)
@staticmethod
def initial_sync() -> Result[None]:
assert AdapterManager._instance
return Result(AdapterManager._instance.ground_truth_adapter.initial_sync)
@staticmethod
def shutdown():
logging.info("AdapterManager shutdown start")
@@ -338,6 +352,13 @@ class AdapterManager:
else:
logging.info(f"{uri} not found. Downloading...")
try:
if REQUEST_DELAY is not None:
delay = random.uniform(*REQUEST_DELAY)
logging.info(
f"REQUEST_DELAY enabled. Pausing for {delay} seconds" # noqa: E501
)
sleep(delay)
data = requests.get(uri)
# TODO (#122): make better

View File

@@ -42,13 +42,13 @@ except Exception:
)
networkmanager_imported = False
SUBSONIC_ADAPTER_DEBUG_DELAY = None
if delay_str := os.environ.get("SUBSONIC_ADAPTER_DEBUG_DELAY"):
SUBSONIC_ADAPTER_DEBUG_DELAY = (
random.uniform(*map(float, delay_str.split(",")))
if "," in delay_str
else float(delay_str)
)
REQUEST_DELAY: Optional[Tuple[float, float]] = None
if delay_str := os.environ.get("REQUEST_DELAY"):
if "," in delay_str:
high, low = map(float, delay_str.split(","))
REQUEST_DELAY = (high, low)
else:
REQUEST_DELAY = (float(delay_str), float(delay_str))
class SubsonicAdapter(Adapter):
@@ -122,13 +122,17 @@ class SubsonicAdapter(Adapter):
self.ping_process = multiprocessing.Process(target=self._check_ping_thread)
self.ping_process.start()
# Wait for the first ping.
# TODO this is kinda dumb. Should probably fix it somehow.
while not self._first_ping_happened.value:
sleep(0.1)
# TODO (#191): support XML?
def initial_sync(self):
# Wait for the ping to happen.
t = 0
while not self._server_available.value:
sleep(0.1)
t += 0.1
if t >= 10: # timeout of 10 seconds on initial synchronization.
break
def shutdown(self):
self.ping_process.terminate()
@@ -142,8 +146,12 @@ class SubsonicAdapter(Adapter):
# since the last successful request is high, then do another ping.
# TODO: also use NM to detect when the connection changes and update
# accordingly.
while True:
# Try 5 times to ping the server.
tries = 0
while not self._server_available.value and tries < 5:
self._set_ping_status()
self._first_ping_happened.value = True
sleep(15)
@@ -233,11 +241,17 @@ class SubsonicAdapter(Adapter):
params = {**self._get_params(), **params}
logging.info(f"[START] get: {url}")
if SUBSONIC_ADAPTER_DEBUG_DELAY:
logging.info(
f"SUBSONIC_ADAPTER_DEBUG_DELAY enabled. Pausing for {SUBSONIC_ADAPTER_DEBUG_DELAY} seconds" # noqa: 512
)
sleep(SUBSONIC_ADAPTER_DEBUG_DELAY)
if REQUEST_DELAY:
delay = random.uniform(*REQUEST_DELAY)
logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds")
sleep(delay)
if timeout:
if type(timeout) == tuple:
if cast(Tuple[float, float], timeout)[0] > delay:
raise TimeoutError("DUMMY TIMEOUT ERROR")
else:
if cast(float, timeout) > delay:
raise TimeoutError("DUMMY TIMEOUT ERROR")
# Deal with datetime parameters (convert to milliseconds since 1970)
for k, v in params.items():

View File

@@ -149,7 +149,8 @@ class SublimeMusicApp(Gtk.Application):
self.window.close()
return
self.update_window()
# TODO remove?
# self.update_window()
# Configure the players
self.last_play_queue_update = timedelta(0)
@@ -226,6 +227,10 @@ class SublimeMusicApp(Gtk.Application):
# Need to do this after we set the current device.
self.player.volume = self.app_config.state.volume
# Update after Adapter Initial Sync
inital_sync_result = AdapterManager.initial_sync()
inital_sync_result.add_done_callback(lambda _: self.update_window())
# Prompt to load the play queue from the server.
if self.app_config.server.sync_enabled:
self.update_play_state_from_server(prompt_confirm=True)
@@ -690,7 +695,7 @@ class SublimeMusicApp(Gtk.Application):
song_queue[:song_index] + song_queue[song_index + 1 :]
)
random.shuffle(song_queue_list)
song_queue = tuple(song_id, *song_queue_list)
song_queue = (song_id, *song_queue_list)
song_index = 0
self.play_song(
@@ -908,13 +913,12 @@ class SublimeMusicApp(Gtk.Application):
dialog.format_secondary_markup(resume_text)
result = dialog.run()
dialog.destroy()
if result != Gtk.ResponseType.YES:
return
self.app_config.state.play_queue = new_play_queue
self.app_config.state.song_progress = play_queue.position
self.app_config.state.current_song_index = play_queue.current_index or 0
if result == Gtk.ResponseType.YES:
self.app_config.state.play_queue = new_play_queue
self.app_config.state.song_progress = play_queue.position
self.app_config.state.current_song_index = (
play_queue.current_index or 0
)
self.player.reset()
self.update_window()

View File

@@ -198,6 +198,7 @@ class AlbumsPanel(Gtk.Box):
self, app_config: AppConfiguration = None, force: bool = False,
):
if not AdapterManager.can_get_genres():
self.updating_query = False
return
def get_genres_done(f: Result):
@@ -216,11 +217,14 @@ class AlbumsPanel(Gtk.Box):
finally:
self.updating_query = False
# Never force. We invalidate the cache ourselves (force is used when
# sort params change). TODO I don't think that is the case now probaly can just
# force=force here
genres_future = AdapterManager.get_genres(force=False)
genres_future.add_done_callback(lambda f: GLib.idle_add(get_genres_done, f))
try:
# Never force. We invalidate the cache ourselves (force is used when sort
# params change). TODO I don't think that is the case now probaly can just
# force=force here
genres_future = AdapterManager.get_genres(force=False)
genres_future.add_done_callback(lambda f: GLib.idle_add(get_genres_done, f))
except Exception:
self.updating_query = False
def update(self, app_config: AppConfiguration = None, force: bool = False):
self.updating_query = True
@@ -252,9 +256,6 @@ class AlbumsPanel(Gtk.Box):
self.prev_page.set_sensitive(self.album_page > 0)
self.page_entry.set_text(str(self.album_page + 1))
# Has to be last because it resets self.updating_query
self.populate_genre_combo(app_config, force=force)
# Show/hide the combo boxes.
def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements):
for element in elements:
@@ -296,6 +297,9 @@ class AlbumsPanel(Gtk.Box):
str(app_config.state.album_page_size)
)
# Has to be last because it resets self.updating_query
self.populate_genre_combo(app_config, force=force)
# At this point, the current query should be totally updated.
self.grid_order_token = self.grid.update_params(self.current_query)
self.grid.update(self.grid_order_token, app_config, force=force)
@@ -812,7 +816,7 @@ class AlbumsGrid(Gtk.Overlay):
# Calculate the page that the currently_selected_index is in. If it's a
# different page, then update the window.
page_changed = False
if self.currently_selected_index:
if self.currently_selected_index is not None:
page_of_selected_index = self.currently_selected_index // self.page_size
if page_of_selected_index != self.page:
page_changed = True