Added initial sync for finishing server ping to avoid blocking the main thread
This commit is contained in:
@@ -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
|
||||
======
|
||||
|
||||
|
@@ -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
|
||||
--------------
|
||||
|
@@ -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):
|
||||
"""
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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():
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user