18 Commits

Author SHA1 Message Date
danhansen
fd40be6809 Fix failure with dataclasses under Python 3.11
This patch was submitted by https://gitlab.com/danhansen in an issue on
the bug tracker, and although I've tested it and endorse it, I wanted to
make it clear this isn't my code.

Signed-off-by: Louis-Philippe Véronneau <pollo@debian.org>
2022-12-01 14:34:28 -05:00
Sumner Evans
793ae50408 deps/{deepdiff,PyGObject}: update to ^5.8.1 and ^3.42.0, respectively
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2022-09-08 10:54:47 -06:00
Sumner Evans
ea20b2667e deps/{requests,python-mpv}: update to ^2.28.1 and ^1.0.1, respectively
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2022-09-08 08:51:18 -06:00
Sumner Evans
f00d92af2a deps/black: update to ^22.8.0, reformat in a few places
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2022-09-08 08:39:37 -06:00
Sumner Evans
b9284f90a3 Merge branch 'fix_deepdiff' into 'master'
Fix deep diff comparison of GObjects

See merge request sublime-music/sublime-music!76
2022-08-18 18:11:12 +00:00
Sumner Evans
84b8704b6d Merge branch 'master' into 'master'
Add Ctrl-Q keyboard shortcut to quit application

See merge request sublime-music/sublime-music!77
2022-08-02 15:41:48 +00:00
buckmelanoma
6a11a3aefd Add Ctrl-Q keyboard shortcut to quit application 2022-08-02 15:41:48 +00:00
César Enrique García Dabó
05f3cdf296 Fix deep diff comparison of GObjects 2022-07-20 18:27:17 +02:00
Sumner Evans
5ec752d6c0 Merge branch 'mpris_wrong_order' into 'master'
Ignore dbus playlist sorting order via dbus if not supported

See merge request sublime-music/sublime-music!75
2022-07-20 03:33:26 +00:00
César Enrique García Dabó
c4ab40ddb0 Ignore dbus playlist sorting order via dbus if not supported 2022-07-19 18:08:01 +02:00
Sumner Evans
c1f42df493 Merge branch 'fix-playlist-ui' into 'master'
Fix Playlist song list updating

See merge request sublime-music/sublime-music!73
2022-04-27 17:23:27 +00:00
Matt Corallo
e8913d552d Fix Playlist song list updating
When updating the song list in a playlist, we get a list of additions and
removals (in the form of table indexes) to do. We then need to do the removals
first to avoid invalidating the table indexes. The previous code did the
additions first, causing exceptions and bogus songs left in the UI.
2022-04-11 21:09:47 +00:00
Sumner Evans
efe038d36c Merge branch 'gapless-playback-mpv' into 'master'
Added basic Gapless Playback support for mpv

Closes #73

See merge request sublime-music/sublime-music!72
2022-02-14 14:21:14 +00:00
t11230
f13007587d Addressing MR comments
- Inverted conditional in PlayerManager.play_media to flatten code some
- Simplified check for gapless_playback in MPVPlayer
- Removed left-over code in SublimeMusicApp.on_track_end
- Changed song-details lookups for gapless playback to always defer
- Updated on_song_download_complete to use new next_song_index property
2022-01-10 21:41:29 -05:00
Sumner Evans
c93568ee84 Merge branch 'ampache-auto-disable-salt-auth' into 'master'
Auto-disable salt_auth for ampache

See merge request sublime-music/sublime-music!70
2022-01-10 15:48:37 +00:00
t11230
8ff857e0a8 Added basic gapless playback for mpv player
- Added property to Player to check whether gapless playback is supported and enabled
- Added method to Player to inform when the next song in a playlist is cached on the local computer
- Added gapless playback setting to the mpv player
- Updated mpv commands for gapless playback support
- Modified PlayerManager to track whether the current song is ending and what the expected next song is
- Modified PlayerManager to not invoke play_media if gapless playback is in use and the next song is expected
- Modified SublimeMusicApp to notify the player manager when the next song in a playlist is cached
2022-01-08 20:55:06 -05:00
t11230
7643a77019 Added next_song_index property
- Consolidated logic for deciding the next song index to select
2022-01-08 14:04:25 -05:00
Benjamin Schaaf
2f5fd81d8d Auto-disable salt_auth for ampache 2022-01-08 18:06:55 +11:00
11 changed files with 731 additions and 589 deletions

1063
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -13,7 +13,6 @@ pkgs.mkShell {
gcc
git
glib
gobjectIntrospection
gtk3
libnotify
pango

View File

@@ -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):

View File

@@ -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,6 +474,7 @@ class SublimeMusicApp(Gtk.Application):
"Created": lambda p: p.created,
"Modified": lambda p: p.changed,
}
if order in sorters:
playlists.sort(
key=sorters.get(order, lambda p: p),
reverse=reverse_order,
@@ -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:
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
song_index_to_play = 0
else:
song_index_to_play = self.app_config.state.current_song_index + 1
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()

View File

@@ -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.
"""

View File

@@ -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,8 +161,33 @@ class PlayerManager:
current_player.set_muted(muted)
def play_media(self, uri: str, progress: timedelta, song: 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
if current_player := self._get_current_player():
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):
@@ -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)

View File

@@ -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")

View File

@@ -87,11 +87,12 @@ class UIState:
def __init__(self):
self.name = "Rock"
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
current_album_search_query: AlbumSearchQuery = field(
default_factory = lambda: AlbumSearchQuery(
AlbumSearchQuery.Type.RANDOM,
genre=_DefaultGenre(),
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)

View File

@@ -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

View File

@@ -761,18 +761,14 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
)
artist = cache_adapter.get_artist("1")
assert (
artist.artist_image_url
and (
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")
)
) == ("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 (
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")
)
) == ("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"),