Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fd40be6809 | ||
![]() |
793ae50408 | ||
![]() |
ea20b2667e | ||
![]() |
f00d92af2a | ||
![]() |
b9284f90a3 | ||
![]() |
84b8704b6d | ||
![]() |
6a11a3aefd | ||
![]() |
05f3cdf296 | ||
![]() |
5ec752d6c0 | ||
![]() |
c4ab40ddb0 | ||
![]() |
c1f42df493 | ||
![]() |
e8913d552d | ||
![]() |
efe038d36c | ||
![]() |
f13007587d | ||
![]() |
c93568ee84 | ||
![]() |
8ff857e0a8 | ||
![]() |
7643a77019 | ||
![]() |
2f5fd81d8d |
1063
poetry.lock
generated
1063
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -36,20 +36,20 @@ python = "^3.8"
|
||||
bleach = ">=3.3.0"
|
||||
bottle = {version = "^0.12.18", optional = true}
|
||||
dataclasses-json = "^0.5.2"
|
||||
deepdiff = "^5.0.2"
|
||||
deepdiff = "^5.8.1"
|
||||
fuzzywuzzy = "^0.18.0"
|
||||
keyring = {version = "^23.0.0", optional = true}
|
||||
peewee = "^3.13.3"
|
||||
pychromecast = {version = "^9.1.1", optional = true}
|
||||
PyGObject = "^3.38.0"
|
||||
PyGObject = "^3.42.0"
|
||||
python-dateutil = "^2.8.1"
|
||||
python-Levenshtein = "^0.12.0"
|
||||
python-mpv = "^0.5.2"
|
||||
requests = "^2.24.0"
|
||||
python-mpv = "^1.0.1"
|
||||
requests = "^2.28.1"
|
||||
semver = "^2.10.2"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
black = "^20.8b1"
|
||||
black = "^22.8.0"
|
||||
docutils = "^0.16"
|
||||
flake8 = "^3.8.3"
|
||||
flake8-annotations = "^2.4.0"
|
||||
|
@@ -13,7 +13,6 @@ pkgs.mkShell {
|
||||
gcc
|
||||
git
|
||||
glib
|
||||
gobjectIntrospection
|
||||
gtk3
|
||||
libnotify
|
||||
pango
|
||||
|
@@ -168,10 +168,11 @@ class SubsonicAdapter(Adapter):
|
||||
"Double check the server address."
|
||||
)
|
||||
except ServerError as e:
|
||||
if e.status_code in (10, 41) and config_store["salt_auth"]:
|
||||
if e.status_code in (10, 40, 41) and config_store["salt_auth"]:
|
||||
# status code 10: if salt auth is not enabled, server will
|
||||
# return error server error with status_code 10 since it'll
|
||||
# interpret it as a missing (password) parameter
|
||||
# status code 41: returned by ampache
|
||||
# status code 41: as per subsonic api docs, description of
|
||||
# status_code 41 is "Token authentication not supported for
|
||||
# LDAP users." so fall back to password auth
|
||||
@@ -288,7 +289,7 @@ class SubsonicAdapter(Adapter):
|
||||
self._set_ping_status(timeout=2 * (i + 1))
|
||||
except Exception:
|
||||
pass
|
||||
sleep(2 ** i)
|
||||
sleep(2**i)
|
||||
i += 1
|
||||
|
||||
def _set_ping_status(self, timeout: int = 2):
|
||||
|
@@ -222,10 +222,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.should_scrobble_song = False
|
||||
|
||||
def on_track_end():
|
||||
at_end = (
|
||||
self.app_config.state.current_song_index
|
||||
== len(self.app_config.state.play_queue) - 1
|
||||
)
|
||||
at_end = self.app_config.state.next_song_index is None
|
||||
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
||||
if at_end and no_repeat:
|
||||
self.app_config.state.playing = False
|
||||
@@ -477,10 +474,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
"Created": lambda p: p.created,
|
||||
"Modified": lambda p: p.changed,
|
||||
}
|
||||
playlists.sort(
|
||||
key=sorters.get(order, lambda p: p),
|
||||
reverse=reverse_order,
|
||||
)
|
||||
if order in sorters:
|
||||
playlists.sort(
|
||||
key=sorters.get(order, lambda p: p),
|
||||
reverse=reverse_order,
|
||||
)
|
||||
|
||||
def make_playlist_tuple(p: Playlist) -> GLib.Variant:
|
||||
cover_art_filename = AdapterManager.get_cover_art_uri(
|
||||
@@ -672,20 +670,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if self.app_config.state.current_song is None:
|
||||
# This may happen due to DBUS, ignore.
|
||||
return
|
||||
# Handle song repeating
|
||||
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
||||
song_index_to_play = self.app_config.state.current_song_index
|
||||
# Wrap around the play queue if at the end.
|
||||
elif (
|
||||
self.app_config.state.current_song_index
|
||||
== len(self.app_config.state.play_queue) - 1
|
||||
):
|
||||
# This may happen due to D-Bus.
|
||||
if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
|
||||
return
|
||||
song_index_to_play = 0
|
||||
else:
|
||||
song_index_to_play = self.app_config.state.current_song_index + 1
|
||||
|
||||
song_index_to_play = self.app_config.state.next_song_index
|
||||
if song_index_to_play is None:
|
||||
# We may end up here due to D-Bus.
|
||||
return
|
||||
|
||||
self.app_config.state.current_song_index = song_index_to_play
|
||||
self.app_config.state.song_progress = timedelta(0)
|
||||
@@ -701,6 +690,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# Go back to the beginning of the song if we are past 5 seconds.
|
||||
# Otherwise, go to the previous song.
|
||||
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
||||
|
||||
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
||||
song_index_to_play = self.app_config.state.current_song_index
|
||||
elif self.app_config.state.song_progress.total_seconds() < 5:
|
||||
@@ -956,6 +946,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
window.search_entry.grab_focus()
|
||||
return False
|
||||
|
||||
if event.keyval == 113 and event.state & Gdk.ModifierType.CONTROL_MASK:
|
||||
# Ctrl + Q
|
||||
window.destroy()
|
||||
return False
|
||||
|
||||
# Allow spaces to work in the text entry boxes.
|
||||
if (
|
||||
window.search_entry.has_focus()
|
||||
@@ -1138,6 +1133,17 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.song_progress = timedelta(0)
|
||||
self.should_scrobble_song = True
|
||||
|
||||
# Tell the player that the next song is available for gapless playback
|
||||
def do_notify_next_song(next_song: Song):
|
||||
try:
|
||||
next_uri = AdapterManager.get_song_file_uri(next_song)
|
||||
if self.player_manager:
|
||||
self.player_manager.next_media_cached(next_uri, next_song)
|
||||
except CacheMissError:
|
||||
logging.debug(
|
||||
"Couldn't find the file for next song for gapless playback"
|
||||
)
|
||||
|
||||
# Do this the old fashioned way so that we can have access to ``reset``
|
||||
# in the callback.
|
||||
@dbus_propagate(self)
|
||||
@@ -1185,6 +1191,16 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.playing = True
|
||||
self.update_window()
|
||||
|
||||
# Check if the next song is available in the cache
|
||||
if (next_song_index := self.app_config.state.next_song_index) is not None:
|
||||
next_song_details_future = AdapterManager.get_song_details(
|
||||
self.app_config.state.play_queue[next_song_index]
|
||||
)
|
||||
|
||||
next_song_details_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(do_notify_next_song, f.result()),
|
||||
)
|
||||
|
||||
# Show a song play notification.
|
||||
if self.app_config.song_play_notification:
|
||||
try:
|
||||
@@ -1273,6 +1289,22 @@ class SublimeMusicApp(Gtk.Application):
|
||||
song,
|
||||
)
|
||||
|
||||
# Handle case where a next-song was previously not cached
|
||||
# but is now available for the player to use
|
||||
if self.app_config.state.playing:
|
||||
next_song_index = self.app_config.state.next_song_index
|
||||
if (
|
||||
next_song_index is not None
|
||||
and self.app_config.state.play_queue[next_song_index] == song_id
|
||||
):
|
||||
next_song_details_future = AdapterManager.get_song_details(
|
||||
song_id
|
||||
)
|
||||
|
||||
next_song_details_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(do_notify_next_song, f.result()),
|
||||
)
|
||||
|
||||
# Always update the window
|
||||
self.update_window()
|
||||
|
||||
|
@@ -94,6 +94,13 @@ class Player(abc.ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def gapless_playback(self) -> bool:
|
||||
"""
|
||||
:returns: whether the player supports and is using gapless playback
|
||||
"""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
|
||||
@@ -213,3 +220,10 @@ class Player(abc.ABC):
|
||||
"""
|
||||
:param position: seek to the given position in the song.
|
||||
"""
|
||||
|
||||
def next_media_cached(self, uri: str, song: Song):
|
||||
"""
|
||||
:param uri: the URI to prepare to play. The URI is guaranteed to be one of
|
||||
the schemes in the :class:`supported_schemes` set for this adapter.
|
||||
:param song: the actual song.
|
||||
"""
|
||||
|
@@ -36,12 +36,14 @@ class PlayerManager:
|
||||
config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]],
|
||||
):
|
||||
self.current_song: Optional[Song] = None
|
||||
self.next_song_uri: Optional[str] = None
|
||||
self.on_timepos_change = on_timepos_change
|
||||
self.on_track_end = on_track_end
|
||||
self.config = config
|
||||
self.players: Dict[Type, Any] = {}
|
||||
self.device_id_type_map: Dict[str, Type] = {}
|
||||
self._current_device_id: Optional[str] = None
|
||||
self._track_ending: bool = False
|
||||
|
||||
def player_event_wrapper(pe: PlayerEvent):
|
||||
if pe.device_id == self._current_device_id:
|
||||
@@ -58,7 +60,7 @@ class PlayerManager:
|
||||
self.players = {
|
||||
player_type: player_type(
|
||||
self.on_timepos_change,
|
||||
self.on_track_end,
|
||||
self._on_track_end,
|
||||
self.on_player_event,
|
||||
self.player_device_change_callback,
|
||||
self.config.get(player_type.name),
|
||||
@@ -91,6 +93,10 @@ class PlayerManager:
|
||||
if current_player_type := self._get_current_player_type():
|
||||
return self.players.get(current_player_type)
|
||||
|
||||
def _on_track_end(self):
|
||||
self._track_ending = True
|
||||
self.on_track_end()
|
||||
|
||||
@property
|
||||
def supported_schemes(self) -> Set[str]:
|
||||
if cp := self._get_current_player():
|
||||
@@ -155,9 +161,34 @@ class PlayerManager:
|
||||
current_player.set_muted(muted)
|
||||
|
||||
def play_media(self, uri: str, progress: timedelta, song: Song):
|
||||
self.current_song = song
|
||||
if current_player := self._get_current_player():
|
||||
current_player.play_media(uri, progress, song)
|
||||
current_player = self._get_current_player()
|
||||
if not current_player:
|
||||
return
|
||||
|
||||
if (
|
||||
current_player.gapless_playback
|
||||
and self.next_song_uri
|
||||
and uri == self.next_song_uri
|
||||
and progress == timedelta(0)
|
||||
and self._track_ending
|
||||
):
|
||||
# In this case the player already knows about the next
|
||||
# song and will automatically play it when the current
|
||||
# song is complete.
|
||||
self.current_song = song
|
||||
self.next_song_uri = None
|
||||
self._track_ending = False
|
||||
current_player.song_loaded = True
|
||||
return
|
||||
|
||||
# If we are changing the current song then the next song
|
||||
# should also be invalidated.
|
||||
if self.current_song != song:
|
||||
self.current_song = song
|
||||
self.next_song_uri = None
|
||||
|
||||
self._track_ending = False
|
||||
current_player.play_media(uri, progress, song)
|
||||
|
||||
def pause(self):
|
||||
if current_player := self._get_current_player():
|
||||
@@ -173,3 +204,10 @@ class PlayerManager:
|
||||
def seek(self, position: timedelta):
|
||||
if current_player := self._get_current_player():
|
||||
current_player.seek(position)
|
||||
|
||||
def next_media_cached(self, uri: str, song: Song):
|
||||
if current_player := self._get_current_player():
|
||||
if current_player.gapless_playback:
|
||||
self.next_song_uri = uri
|
||||
|
||||
current_player.next_media_cached(uri, song)
|
||||
|
@@ -8,6 +8,7 @@ from .base import Player, PlayerDeviceEvent, PlayerEvent
|
||||
from ..adapters.api_objects import Song
|
||||
|
||||
REPLAY_GAIN_KEY = "Replay Gain"
|
||||
GAPLESS_PLAYBACK_KEY = "Gapless Playback"
|
||||
|
||||
|
||||
class MPVPlayer(Player):
|
||||
@@ -27,7 +28,10 @@ class MPVPlayer(Player):
|
||||
|
||||
@staticmethod
|
||||
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
|
||||
return {REPLAY_GAIN_KEY: ("Disabled", "Track", "Album")}
|
||||
return {
|
||||
REPLAY_GAIN_KEY: ("Disabled", "Track", "Album"),
|
||||
GAPLESS_PLAYBACK_KEY: ("Disabled", "Enabled"),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -100,6 +104,10 @@ class MPVPlayer(Player):
|
||||
def playing(self) -> bool:
|
||||
return not self.mpv.pause
|
||||
|
||||
@property
|
||||
def gapless_playback(self) -> bool:
|
||||
return self.config.get(GAPLESS_PLAYBACK_KEY) == "Enabled"
|
||||
|
||||
def get_volume(self) -> float:
|
||||
return self._volume
|
||||
|
||||
@@ -119,6 +127,9 @@ class MPVPlayer(Player):
|
||||
with self._progress_value_lock:
|
||||
self._progress_value_count = 0
|
||||
|
||||
# Clears everything except the currently-playing song
|
||||
self.mpv.command("playlist-clear")
|
||||
|
||||
options = {
|
||||
"force-seekable": "yes",
|
||||
"start": str(progress.total_seconds()),
|
||||
@@ -137,3 +148,12 @@ class MPVPlayer(Player):
|
||||
|
||||
def seek(self, position: timedelta):
|
||||
self.mpv.seek(str(position.total_seconds()), "absolute")
|
||||
|
||||
def next_media_cached(self, uri: str, song: Song):
|
||||
if not self.gapless_playback:
|
||||
return
|
||||
|
||||
# Ensure the only 2 things in the playlist are the current song
|
||||
# and the next song for gapless playback
|
||||
self.mpv.command("playlist-clear")
|
||||
self.mpv.command("loadfile", uri, "append")
|
||||
|
@@ -87,11 +87,12 @@ class UIState:
|
||||
def __init__(self):
|
||||
self.name = "Rock"
|
||||
|
||||
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
|
||||
AlbumSearchQuery.Type.RANDOM,
|
||||
genre=_DefaultGenre(),
|
||||
year_range=this_decade(),
|
||||
)
|
||||
current_album_search_query: AlbumSearchQuery = field(
|
||||
default_factory = lambda: AlbumSearchQuery(
|
||||
AlbumSearchQuery.Type.RANDOM,
|
||||
genre=UIState._DefaultGenre(),
|
||||
year_range=this_decade(),
|
||||
))
|
||||
|
||||
active_playlist_id: Optional[str] = None
|
||||
|
||||
@@ -137,6 +138,28 @@ class UIState:
|
||||
|
||||
return self._current_song
|
||||
|
||||
@property
|
||||
def next_song_index(self) -> Optional[int]:
|
||||
# If nothing is playing there is no next song
|
||||
if self.current_song_index < 0:
|
||||
return None
|
||||
|
||||
if self.repeat_type == RepeatType.REPEAT_SONG:
|
||||
return self.current_song_index
|
||||
|
||||
# If we are at the end of the play queue
|
||||
if self.current_song_index == len(self.play_queue) - 1:
|
||||
|
||||
# If we are repeating the queue, jump back to the beginning
|
||||
if self.repeat_type == RepeatType.REPEAT_QUEUE:
|
||||
return 0
|
||||
|
||||
# Otherwise, there isn't a next song
|
||||
return None
|
||||
|
||||
# In all other cases, it's the song after the current one
|
||||
return self.current_song_index + 1
|
||||
|
||||
@property
|
||||
def volume(self) -> float:
|
||||
return self._volume.get(self.current_device, 100.0)
|
||||
|
@@ -20,6 +20,8 @@ from ..adapters import AdapterManager, CacheMissError, Result, SongCacheStatus
|
||||
from ..adapters.api_objects import Playlist, Song
|
||||
from ..config import AppConfiguration
|
||||
|
||||
deep_diff_exclude_regexp = re.compile(r"root\[\d+\]\.props")
|
||||
|
||||
|
||||
def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str:
|
||||
"""
|
||||
@@ -148,13 +150,13 @@ def diff_song_store(store_to_edit: Any, new_store: Iterable[Any]):
|
||||
idx, field = _parse_diff_location(edit_location)
|
||||
store_to_edit[int(idx)][int(field)] = diff["new_value"]
|
||||
|
||||
for _, value in added.items():
|
||||
store_to_edit.append(value)
|
||||
|
||||
for remove_location, _ in reversed(list(removed.items())):
|
||||
remove_at = int(_parse_diff_location(remove_location)[0])
|
||||
del store_to_edit[remove_at]
|
||||
|
||||
for _, value in added.items():
|
||||
store_to_edit.append(value)
|
||||
|
||||
|
||||
def diff_model_store(store_to_edit: Any, new_store: Iterable[Any]):
|
||||
"""
|
||||
@@ -163,7 +165,7 @@ def diff_model_store(store_to_edit: Any, new_store: Iterable[Any]):
|
||||
"""
|
||||
old_store = store_to_edit[:]
|
||||
|
||||
diff = DeepDiff(old_store, new_store)
|
||||
diff = DeepDiff(old_store, new_store, exclude_regex_paths=deep_diff_exclude_regexp)
|
||||
if diff == {}:
|
||||
return
|
||||
|
||||
|
@@ -761,18 +761,14 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
|
||||
)
|
||||
|
||||
artist = cache_adapter.get_artist("1")
|
||||
assert (
|
||||
artist.artist_image_url
|
||||
and (
|
||||
artist.id,
|
||||
artist.name,
|
||||
artist.album_count,
|
||||
artist.artist_image_url,
|
||||
artist.biography,
|
||||
artist.music_brainz_id,
|
||||
)
|
||||
== ("1", "Bar", 1, "image", "this is a bio", "mbid")
|
||||
)
|
||||
assert artist.artist_image_url and (
|
||||
artist.id,
|
||||
artist.name,
|
||||
artist.album_count,
|
||||
artist.artist_image_url,
|
||||
artist.biography,
|
||||
artist.music_brainz_id,
|
||||
) == ("1", "Bar", 1, "image", "this is a bio", "mbid")
|
||||
assert artist.similar_artists == [
|
||||
SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
|
||||
SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
|
||||
@@ -805,18 +801,14 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
|
||||
)
|
||||
|
||||
artist = cache_adapter.get_artist("1")
|
||||
assert (
|
||||
artist.artist_image_url
|
||||
and (
|
||||
artist.id,
|
||||
artist.name,
|
||||
artist.album_count,
|
||||
artist.artist_image_url,
|
||||
artist.biography,
|
||||
artist.music_brainz_id,
|
||||
)
|
||||
== ("1", "Foo", 2, "image2", "this is a bio2", "mbid2")
|
||||
)
|
||||
assert artist.artist_image_url and (
|
||||
artist.id,
|
||||
artist.name,
|
||||
artist.album_count,
|
||||
artist.artist_image_url,
|
||||
artist.biography,
|
||||
artist.music_brainz_id,
|
||||
) == ("1", "Foo", 2, "image2", "this is a bio2", "mbid2")
|
||||
assert artist.similar_artists == [
|
||||
SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
|
||||
SubsonicAPI.ArtistAndArtistInfo(id="E", name="F"),
|
||||
|
Reference in New Issue
Block a user