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