Rudimentary progress bars
This commit is contained in:
@@ -6,7 +6,7 @@ from .adapter_base import (
|
||||
ConfigParamDescriptor,
|
||||
SongCacheStatus,
|
||||
)
|
||||
from .manager import AdapterManager, Result, SearchResult
|
||||
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
|
||||
|
||||
__all__ = (
|
||||
"Adapter",
|
||||
@@ -15,6 +15,7 @@ __all__ = (
|
||||
"CacheMissError",
|
||||
"CachingAdapter",
|
||||
"ConfigParamDescriptor",
|
||||
"DownloadProgress",
|
||||
"Result",
|
||||
"SearchResult",
|
||||
"SongCacheStatus",
|
||||
|
@@ -95,6 +95,7 @@ class Song(abc.ABC):
|
||||
disc_number: Optional[int]
|
||||
year: Optional[int]
|
||||
cover_art: Optional[str]
|
||||
size: Optional[int]
|
||||
user_rating: Optional[int]
|
||||
starred: Optional[datetime]
|
||||
|
||||
|
@@ -614,7 +614,9 @@ class FilesystemAdapter(CachingAdapter):
|
||||
if api_song.cover_art
|
||||
else None,
|
||||
"file": self._do_ingest_new_data(
|
||||
KEYS.SONG_FILE, api_song.id, data=(api_song.path, None)
|
||||
KEYS.SONG_FILE,
|
||||
api_song.id,
|
||||
data=(api_song.path, None, api_song.size),
|
||||
)
|
||||
if api_song.path
|
||||
else None,
|
||||
@@ -788,11 +790,14 @@ class FilesystemAdapter(CachingAdapter):
|
||||
|
||||
# Special handling for Song
|
||||
if data_key == KEYS.SONG_FILE and data:
|
||||
path, buffer_filename = data
|
||||
path, buffer_filename, size = data
|
||||
|
||||
if path:
|
||||
cache_info.path = path
|
||||
|
||||
if size:
|
||||
cache_info.size = size
|
||||
|
||||
if buffer_filename:
|
||||
cache_info.file_hash = compute_file_hash(buffer_filename)
|
||||
|
||||
|
@@ -43,6 +43,7 @@ class CacheInfo(BaseModel):
|
||||
# Used for cached files.
|
||||
file_id = TextField(null=True)
|
||||
file_hash = TextField(null=True)
|
||||
size = IntegerField(null=True)
|
||||
path = TextField(null=True)
|
||||
cache_permanently = BooleanField(null=True)
|
||||
|
||||
@@ -165,6 +166,13 @@ class Song(BaseModel):
|
||||
# figure out how to deal with different transcodings, etc.
|
||||
file = ForeignKeyField(CacheInfo, null=True)
|
||||
|
||||
@property
|
||||
def size(self) -> Optional[int]:
|
||||
try:
|
||||
return self.file.size
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def path(self) -> Optional[str]:
|
||||
try:
|
||||
|
@@ -8,6 +8,7 @@ import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
@@ -58,9 +59,13 @@ if delay_str := os.environ.get("REQUEST_DELAY"):
|
||||
REQUEST_DELAY = (float(delay_str), float(delay_str))
|
||||
|
||||
NETWORK_ALWAYS_ERROR: bool = False
|
||||
if always_error := os.environ.get("NETWORK_ALWAYS_ERROR"):
|
||||
if os.environ.get("NETWORK_ALWAYS_ERROR"):
|
||||
NETWORK_ALWAYS_ERROR = True
|
||||
|
||||
DOWNLOAD_BLOCK_DELAY: Optional[float] = None
|
||||
if delay_str := os.environ.get("DOWNLOAD_BLOCK_DELAY"):
|
||||
DOWNLOAD_BLOCK_DELAY = float(delay_str)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -159,6 +164,27 @@ class Result(Generic[T]):
|
||||
return self._data is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadProgress:
|
||||
class Type(Enum):
|
||||
QUEUED = 0
|
||||
PROGRESS = 1
|
||||
DONE = 2
|
||||
CANCELLED = 3
|
||||
ERROR = 4
|
||||
|
||||
type: Type
|
||||
total_bytes: Optional[int] = None
|
||||
current_bytes: Optional[int] = None
|
||||
exception: Optional[Exception] = None
|
||||
|
||||
@property
|
||||
def progress_fraction(self) -> Optional[float]:
|
||||
if not self.current_bytes or not self.total_bytes:
|
||||
return None
|
||||
return self.current_bytes / self.total_bytes
|
||||
|
||||
|
||||
class AdapterManager:
|
||||
available_adapters: Set[Any] = {FilesystemAdapter, SubsonicAdapter}
|
||||
current_download_ids: Set[str] = set()
|
||||
@@ -171,6 +197,7 @@ class AdapterManager:
|
||||
@dataclass
|
||||
class _AdapterManagerInternal:
|
||||
ground_truth_adapter: Adapter
|
||||
on_song_download_progress: Callable[[Any, str, DownloadProgress], None]
|
||||
caching_adapter: Optional[CachingAdapter] = None
|
||||
concurrent_download_limit: int = 5
|
||||
|
||||
@@ -181,6 +208,9 @@ class AdapterManager:
|
||||
self.concurrent_download_limit
|
||||
)
|
||||
|
||||
def song_download_progress(self, file_id: str, progress: DownloadProgress):
|
||||
self.on_song_download_progress(file_id, progress)
|
||||
|
||||
def shutdown(self):
|
||||
self.ground_truth_adapter.shutdown()
|
||||
if self.caching_adapter:
|
||||
@@ -231,8 +261,10 @@ class AdapterManager:
|
||||
logging.info("AdapterManager shutdown complete")
|
||||
|
||||
@staticmethod
|
||||
def reset(config: Any):
|
||||
|
||||
def reset(
|
||||
config: Any,
|
||||
on_song_download_progress: Callable[[Any, str, DownloadProgress], None],
|
||||
):
|
||||
from sublime.config import AppConfiguration
|
||||
|
||||
assert isinstance(config, AppConfiguration)
|
||||
@@ -273,6 +305,7 @@ class AdapterManager:
|
||||
|
||||
AdapterManager._instance = AdapterManager._AdapterManagerInternal(
|
||||
ground_truth_adapter,
|
||||
on_song_download_progress,
|
||||
caching_adapter=caching_adapter,
|
||||
concurrent_download_limit=config.concurrent_download_limit,
|
||||
)
|
||||
@@ -345,7 +378,10 @@ class AdapterManager:
|
||||
|
||||
@staticmethod
|
||||
def _create_download_fn(
|
||||
uri: str, id: str, before_download: Callable[[], None] = None,
|
||||
uri: str,
|
||||
id: str,
|
||||
before_download: Callable[[], None] = None,
|
||||
expected_size: int = None,
|
||||
) -> Callable[[], str]:
|
||||
"""
|
||||
Create a function to download the given URI to a temporary file, and return the
|
||||
@@ -368,6 +404,8 @@ class AdapterManager:
|
||||
if before_download:
|
||||
before_download()
|
||||
|
||||
expected_size_exists = expected_size is not None
|
||||
|
||||
# TODO (#122): figure out how to retry if the other request failed.
|
||||
if resource_downloading:
|
||||
logging.info(f"{uri} already being downloaded.")
|
||||
@@ -393,16 +431,57 @@ class AdapterManager:
|
||||
if NETWORK_ALWAYS_ERROR:
|
||||
raise Exception("NETWORK_ALWAYS_ERROR enabled")
|
||||
|
||||
data = requests.get(uri)
|
||||
|
||||
# TODO (#122): make better
|
||||
if not data:
|
||||
raise Exception("Download failed!")
|
||||
if "json" in data.headers.get("Content-Type", ""):
|
||||
# Wait 10 seconds to connect to the server and start downloading.
|
||||
# Then, for each of the blocks, give 5 seconds to download (which
|
||||
# should be more than enough for 1 KiB).
|
||||
request = requests.get(uri, stream=True, timeout=(10, 5))
|
||||
if "json" in request.headers.get("Content-Type", ""):
|
||||
raise Exception("Didn't expect JSON!")
|
||||
|
||||
total_size = int(request.headers.get("Content-Length", 0))
|
||||
if expected_size_exists:
|
||||
if total_size != expected_size:
|
||||
raise Exception(
|
||||
f"Download content size ({total_size})is not the "
|
||||
f"expected size ({expected_size})."
|
||||
)
|
||||
|
||||
block_size = 1024 # 1 KiB
|
||||
total_consumed = 0
|
||||
|
||||
with open(download_tmp_filename, "wb+") as f:
|
||||
f.write(data.content)
|
||||
for i, data in enumerate(request.iter_content(block_size)):
|
||||
total_consumed += len(data)
|
||||
f.write(data)
|
||||
|
||||
if i % 5 == 0:
|
||||
# Only delay (if configured) and update the progress UI
|
||||
# every 5 KiB.
|
||||
if DOWNLOAD_BLOCK_DELAY is not None:
|
||||
sleep(DOWNLOAD_BLOCK_DELAY)
|
||||
|
||||
if expected_size_exists:
|
||||
AdapterManager._instance.song_download_progress(
|
||||
id,
|
||||
DownloadProgress(
|
||||
DownloadProgress.Type.PROGRESS,
|
||||
total_bytes=total_size,
|
||||
current_bytes=total_consumed,
|
||||
),
|
||||
)
|
||||
|
||||
# Everything succeeded.
|
||||
if expected_size_exists:
|
||||
AdapterManager._instance.song_download_progress(
|
||||
id, DownloadProgress(DownloadProgress.Type.DONE),
|
||||
)
|
||||
except Exception as e:
|
||||
if expected_size_exists:
|
||||
# Something failed. Post an error.
|
||||
AdapterManager._instance.song_download_progress(
|
||||
id,
|
||||
DownloadProgress(DownloadProgress.Type.ERROR, exception=e),
|
||||
)
|
||||
finally:
|
||||
# Always release the download set lock, even if there's an error.
|
||||
with AdapterManager.download_set_lock:
|
||||
@@ -872,11 +951,17 @@ class AdapterManager:
|
||||
AdapterManager._instance.caching_adapter.get_song_uri(
|
||||
song_id, "file"
|
||||
)
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.DONE),
|
||||
)
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
except CacheMissError:
|
||||
# The song is not already cached.
|
||||
if before_download:
|
||||
before_download(song_id)
|
||||
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
|
||||
# Download the song.
|
||||
# TODO (#64) handle download errors?
|
||||
song_tmp_filename = AdapterManager._create_download_fn(
|
||||
@@ -885,16 +970,16 @@ class AdapterManager:
|
||||
),
|
||||
song_id,
|
||||
lambda: before_download(song_id),
|
||||
expected_size=song.size,
|
||||
)()
|
||||
AdapterManager._instance.caching_adapter.ingest_new_data(
|
||||
CachingAdapter.CachedDataKey.SONG_FILE,
|
||||
song_id,
|
||||
(None, song_tmp_filename),
|
||||
(None, song_tmp_filename, None),
|
||||
)
|
||||
on_song_download_complete(song_id)
|
||||
|
||||
# Download the corresponding cover art.
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
AdapterManager.get_cover_art_filename(song.cover_art).result()
|
||||
finally:
|
||||
# Release the semaphore lock. This will allow the next song in the queue
|
||||
@@ -905,6 +990,20 @@ class AdapterManager:
|
||||
|
||||
def do_batch_download_songs():
|
||||
sleep(delay)
|
||||
if (
|
||||
AdapterManager.is_shutting_down
|
||||
or AdapterManager._offline_mode
|
||||
or cancelled
|
||||
):
|
||||
return
|
||||
|
||||
# Alert the UI that the downloads are queue.
|
||||
for song_id in song_ids:
|
||||
# Everything succeeded.
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.QUEUED),
|
||||
)
|
||||
|
||||
for song_id in song_ids:
|
||||
if (
|
||||
AdapterManager.is_shutting_down
|
||||
@@ -926,6 +1025,13 @@ class AdapterManager:
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
# Alert the UI that the downloads are cancelled.
|
||||
for song_id in song_ids:
|
||||
# Everything succeeded.
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.CANCELLED),
|
||||
)
|
||||
|
||||
return Result(do_batch_download_songs, is_download=True, on_cancel=on_cancel)
|
||||
|
||||
@staticmethod
|
||||
|
@@ -188,6 +188,7 @@ class Song(SublimeAPI.Song, DataClassJsonMixin):
|
||||
track: Optional[int] = None
|
||||
disc_number: Optional[int] = None
|
||||
year: Optional[int] = None
|
||||
size: Optional[int] = None
|
||||
cover_art: Optional[str] = None
|
||||
user_rating: Optional[int] = None
|
||||
starred: Optional[datetime] = None
|
||||
|
@@ -31,7 +31,13 @@ except Exception:
|
||||
)
|
||||
glib_notify_exists = False
|
||||
|
||||
from .adapters import AdapterManager, AlbumSearchQuery, Result, SongCacheStatus
|
||||
from .adapters import (
|
||||
AdapterManager,
|
||||
AlbumSearchQuery,
|
||||
DownloadProgress,
|
||||
Result,
|
||||
SongCacheStatus,
|
||||
)
|
||||
from .adapters.api_objects import Playlist, PlayQueue, Song
|
||||
from .config import AppConfiguration
|
||||
from .dbus import dbus_propagate, DBusManager
|
||||
@@ -133,6 +139,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
# Load the state for the server, if it exists.
|
||||
self.app_config.load_state()
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
|
||||
# If there is no current server, show the dialog to select a server.
|
||||
if self.app_config.server is None:
|
||||
@@ -823,6 +830,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
@dbus_propagate()
|
||||
def on_volume_change(self, _, value: float):
|
||||
assert self.player
|
||||
self.app_config.state.volume = value
|
||||
self.player.volume = self.app_config.state.volume
|
||||
self.update_window()
|
||||
@@ -855,6 +863,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
return False
|
||||
|
||||
def on_song_download_progress(self, song_id: str, progress: DownloadProgress):
|
||||
assert self.window
|
||||
self.window.update_song_download_progress(song_id, progress)
|
||||
|
||||
def on_app_shutdown(self, app: "SublimeMusicApp"):
|
||||
self.exiting = True
|
||||
if glib_notify_exists:
|
||||
@@ -866,7 +878,8 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if self.app_config.server is None:
|
||||
return
|
||||
|
||||
self.player.pause()
|
||||
if self.player:
|
||||
self.player.pause()
|
||||
self.chromecast_player.shutdown()
|
||||
self.mpv_player.shutdown()
|
||||
|
||||
|
@@ -172,11 +172,6 @@ class AppConfiguration:
|
||||
# Just ignore any errors, it is only UI state.
|
||||
self._state = UIState()
|
||||
|
||||
# Do the import in the function to avoid circular imports.
|
||||
from sublime.adapters import AdapterManager
|
||||
|
||||
AdapterManager.reset(self)
|
||||
|
||||
@property
|
||||
def state_file_location(self) -> Optional[Path]:
|
||||
if self.server is None:
|
||||
|
@@ -2,6 +2,7 @@
|
||||
#connected-to-label {
|
||||
margin: 5px 15px;
|
||||
font-size: 1.2em;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#connected-status-row {
|
||||
@@ -27,6 +28,10 @@
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
#current-downloads-list-placeholder {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
@@ -3,7 +3,12 @@ from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
from gi.repository import Gdk, GLib, GObject, Gtk, Pango
|
||||
|
||||
from sublime.adapters import AdapterManager, api_objects as API, Result
|
||||
from sublime.adapters import (
|
||||
AdapterManager,
|
||||
api_objects as API,
|
||||
DownloadProgress,
|
||||
Result,
|
||||
)
|
||||
from sublime.config import AppConfiguration, ReplayGainType
|
||||
from sublime.ui import albums, artists, browse, player_controls, playlists, util
|
||||
from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage
|
||||
@@ -29,6 +34,7 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
}
|
||||
|
||||
_updating_settings: bool = False
|
||||
_current_download_boxes: Dict[str, Gtk.Box] = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -184,6 +190,39 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
|
||||
self.player_controls.update(app_config, force=force)
|
||||
|
||||
def update_song_download_progress(self, song_id: str, progress: DownloadProgress):
|
||||
if progress.type == DownloadProgress.Type.QUEUED:
|
||||
# Create and add the box to show the progress.
|
||||
self._current_download_boxes[song_id] = DownloadStatusBox(song_id)
|
||||
self.current_downloads_box.add(self._current_download_boxes[song_id])
|
||||
elif progress.type in (
|
||||
DownloadProgress.Type.DONE,
|
||||
DownloadProgress.Type.CANCELLED,
|
||||
):
|
||||
# Remove and delet the box for the download.
|
||||
if song_id in self._current_download_boxes:
|
||||
self.current_downloads_box.remove(self._current_download_boxes[song_id])
|
||||
del self._current_download_boxes[song_id]
|
||||
elif progress.type == DownloadProgress.Type.ERROR:
|
||||
GLib.idle_add(
|
||||
self._current_download_boxes[song_id].set_error, progress.exception
|
||||
)
|
||||
elif progress.type == DownloadProgress.Type.PROGRESS:
|
||||
GLib.idle_add(
|
||||
self._current_download_boxes[song_id].update_progress,
|
||||
progress.progress_fraction,
|
||||
)
|
||||
|
||||
if len(self._current_download_boxes) == 0:
|
||||
self.current_downloads_placeholder.show()
|
||||
else:
|
||||
self.current_downloads_placeholder.hide()
|
||||
|
||||
def _create_download_status_box(self, song_id: str) -> Gtk.Box:
|
||||
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
return status_box
|
||||
|
||||
def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack:
|
||||
stack = Gtk.Stack()
|
||||
for name, child in kwargs.items():
|
||||
@@ -340,6 +379,12 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
self.current_downloads_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, name="current-downloads-list"
|
||||
)
|
||||
self.current_downloads_placeholder = Gtk.Label(
|
||||
label="<i>No current downloads</i>",
|
||||
use_markup=True,
|
||||
name="current-downloads-list-placeholder",
|
||||
)
|
||||
self.current_downloads_box.add(self.current_downloads_placeholder)
|
||||
vbox.add(self.current_downloads_box)
|
||||
|
||||
clear_cache = self._create_model_button("Clear Cache", menu_name="clear-cache")
|
||||
@@ -847,3 +892,28 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class DownloadStatusBox(Gtk.Box):
|
||||
def __init__(self, song_id: str):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
|
||||
self.song_label = Gtk.Label(label=song.title, halign=Gtk.Align.START)
|
||||
self.pack_start(self.song_label, True, True, 5)
|
||||
|
||||
self.download_progress = Gtk.ProgressBar(show_text=True)
|
||||
self.download_progress.set_text(song.title)
|
||||
self.add(self.download_progress)
|
||||
|
||||
self.cancel_button = IconButton(
|
||||
"process-stop-symbolic", tooltip_text="Cancel download", relief=True
|
||||
)
|
||||
self.add(self.cancel_button)
|
||||
|
||||
def update_progress(self, progress_fraction: float):
|
||||
self.download_progress.set_fraction(progress_fraction)
|
||||
|
||||
def set_error(self, exception: Exception):
|
||||
print(exception)
|
||||
|
@@ -19,7 +19,7 @@ def adapter_manager(tmp_path: Path):
|
||||
current_server_index=0,
|
||||
cache_location=tmp_path.as_posix(),
|
||||
)
|
||||
AdapterManager.reset(config)
|
||||
AdapterManager.reset(config, lambda *a: None)
|
||||
yield
|
||||
AdapterManager.shutdown()
|
||||
|
||||
|
Reference in New Issue
Block a user