Bug fixes; diffing of the playlist list
This commit is contained in:
@@ -40,6 +40,10 @@ class LibremsonicApp(Gtk.Application):
|
||||
|
||||
self.last_play_queue_update = 0
|
||||
|
||||
# Need to do this for determining if we are at the end of the song.
|
||||
# It's dumb, but whatever.
|
||||
self.had_progress_value = False
|
||||
|
||||
@self.player.property_observer('time-pos')
|
||||
def time_observer(_name, value):
|
||||
self.state.song_progress = value
|
||||
@@ -48,13 +52,15 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.state.song_progress,
|
||||
self.state.current_song.duration,
|
||||
)
|
||||
if abs((value or 0) - self.state.current_song.duration) < 0.1:
|
||||
if value is None and self.had_progress_value:
|
||||
GLib.idle_add(self.on_next_track, None, None)
|
||||
self.had_progress_value = False
|
||||
|
||||
if not value:
|
||||
self.last_play_queue_update = 0
|
||||
elif self.last_play_queue_update + 15 <= value:
|
||||
self.save_play_queue()
|
||||
self.had_progress_value = True
|
||||
|
||||
# Handle command line option parsing.
|
||||
def do_command_line(self, command_line):
|
||||
@@ -147,6 +153,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.play_song(self.state.current_song.id)
|
||||
else:
|
||||
self.player.cycle('pause')
|
||||
self.save_play_queue()
|
||||
|
||||
self.state.playing = not self.state.playing
|
||||
self.update_window()
|
||||
@@ -161,9 +168,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
elif current_idx == len(self.state.play_queue) - 1:
|
||||
current_idx = -1
|
||||
|
||||
self.state.song_progress = 0
|
||||
self.play_song(self.state.play_queue[current_idx + 1])
|
||||
self.save_play_queue(position=0)
|
||||
self.play_song(self.state.play_queue[current_idx + 1], reset=True)
|
||||
|
||||
def on_prev_track(self, action, params):
|
||||
current_idx = self.state.play_queue.index(self.state.current_song.id)
|
||||
@@ -180,9 +185,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
else:
|
||||
song_to_play = current_idx
|
||||
|
||||
self.state.song_progress = 0
|
||||
self.play_song(self.state.play_queue[song_to_play])
|
||||
self.save_play_queue(position=0)
|
||||
self.play_song(self.state.play_queue[song_to_play], reset=True)
|
||||
|
||||
def on_repeat_press(self, action, params):
|
||||
# Cycle through the repeat types.
|
||||
@@ -203,7 +206,6 @@ class LibremsonicApp(Gtk.Application):
|
||||
random.shuffle(self.state.play_queue)
|
||||
self.state.play_queue = [song_id] + self.state.play_queue
|
||||
|
||||
self.save_play_queue()
|
||||
self.state.shuffle_on = not self.state.shuffle_on
|
||||
self.update_window()
|
||||
|
||||
@@ -229,10 +231,9 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
def on_song_clicked(self, win, song_id, song_queue):
|
||||
self.state.play_queue = song_queue
|
||||
self.state.song_progress = 0
|
||||
self.play_song(song_id)
|
||||
self.save_play_queue(position=0)
|
||||
# Need to reset this here to prevent it from going to the next song.
|
||||
self.had_progress_value = False
|
||||
self.play_song(song_id, reset=True, play_queue=song_queue)
|
||||
|
||||
def on_song_scrub(self, _, scrub_value):
|
||||
if not hasattr(self.state, 'current_song'):
|
||||
@@ -274,6 +275,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
return True
|
||||
|
||||
def on_app_shutdown(self, app):
|
||||
self.player.pause = True
|
||||
self.state.save()
|
||||
self.save_play_queue()
|
||||
|
||||
@@ -290,33 +292,44 @@ class LibremsonicApp(Gtk.Application):
|
||||
def update_window(self):
|
||||
GLib.idle_add(self.window.update, self.state)
|
||||
|
||||
@util.async_callback(
|
||||
lambda *a, **k: CacheManager.get_song_details(*a, **k),
|
||||
)
|
||||
def play_song(self, song: Child):
|
||||
# Do this the old fashioned way so that we can have access to ``song``
|
||||
def play_song(self, song: Child, reset=False, play_queue=None):
|
||||
# Do this the old fashioned way so that we can have access to ``reset``
|
||||
# in the callback.
|
||||
song_filename_future = CacheManager.get_song_filename(
|
||||
song,
|
||||
before_download=lambda: None,
|
||||
)
|
||||
def do_play_song(song: Child):
|
||||
# Do this the old fashioned way so that we can have access to
|
||||
# ``song`` in the callback.
|
||||
def filename_future_done(song_file):
|
||||
self.state.current_song = song
|
||||
self.state.playing = True
|
||||
self.update_window()
|
||||
|
||||
def filename_future_done(song_file):
|
||||
self.state.current_song = song
|
||||
self.state.playing = True
|
||||
self.update_window()
|
||||
# Prevent it from doing the thing where it continually loads
|
||||
# songs when it has to download.
|
||||
if reset:
|
||||
self.had_progress_value = False
|
||||
self.state.song_progress = 0
|
||||
|
||||
print(self.state.song_progress)
|
||||
self.player.command('loadfile', song_file, 'replace',
|
||||
f'start={self.state.song_progress}')
|
||||
self.player.pause = False
|
||||
self.player.command('loadfile', song_file, 'replace',
|
||||
f'start={self.state.song_progress}')
|
||||
self.player.pause = False
|
||||
|
||||
song_filename_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(filename_future_done, f.result()), )
|
||||
if play_queue:
|
||||
self.state.play_queue = play_queue
|
||||
self.save_play_queue()
|
||||
|
||||
def save_play_queue(self, position=None):
|
||||
if position is None:
|
||||
position = self.state.song_progress
|
||||
song_filename_future = CacheManager.get_song_filename(
|
||||
song,
|
||||
before_download=lambda: self.update_window(),
|
||||
)
|
||||
song_filename_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(filename_future_done, f.result()), )
|
||||
|
||||
song_details_future = CacheManager.get_song_details(song)
|
||||
song_details_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(do_play_song, f.result()), )
|
||||
|
||||
def save_play_queue(self):
|
||||
position = self.state.song_progress
|
||||
self.last_play_queue_update = position
|
||||
CacheManager.executor.submit(lambda: CacheManager.save_play_queue(
|
||||
id=self.state.play_queue,
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import threading
|
||||
import shutil
|
||||
import json
|
||||
|
||||
@@ -6,7 +7,6 @@ from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from enum import EnumMeta, Enum
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Union, Callable, Set
|
||||
|
||||
from libremsonic.config import AppConfiguration, ServerConfiguration
|
||||
@@ -38,7 +38,7 @@ class SongCacheStatus(Enum):
|
||||
|
||||
|
||||
class CacheManager(metaclass=Singleton):
|
||||
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=5)
|
||||
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50)
|
||||
|
||||
class CacheEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
@@ -58,6 +58,13 @@ class CacheManager(metaclass=Singleton):
|
||||
permanently_cached_paths: Set[str] = set()
|
||||
song_details: Dict[int, Child] = {}
|
||||
|
||||
# Thread lock for preventing threads from overriding the state while
|
||||
# it's being saved.
|
||||
cache_lock = threading.Lock()
|
||||
|
||||
download_set_lock = threading.Lock()
|
||||
current_downloads: Set[Path] = set()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_config: AppConfiguration,
|
||||
@@ -104,7 +111,7 @@ class CacheManager(metaclass=Singleton):
|
||||
os.makedirs(self.app_config.cache_location, exist_ok=True)
|
||||
|
||||
cache_meta_file = self.calculate_abs_path('.cache_meta')
|
||||
with open(cache_meta_file, 'w+') as f:
|
||||
with open(cache_meta_file, 'w+') as f, self.cache_lock:
|
||||
cache_info = dict(
|
||||
playlists=self.playlists,
|
||||
playlist_details=self.playlist_details,
|
||||
@@ -137,20 +144,29 @@ class CacheManager(metaclass=Singleton):
|
||||
self,
|
||||
relative_path: Union[Path, str],
|
||||
download_fn: Callable[[], bytes],
|
||||
before_download: Callable[[], None],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
):
|
||||
abs_path = self.calculate_abs_path(relative_path)
|
||||
download_path = self.calculate_download_path(relative_path)
|
||||
if not abs_path.exists() or force:
|
||||
before_download()
|
||||
print(abs_path, 'not found. Downloading...')
|
||||
|
||||
with self.download_set_lock:
|
||||
self.current_downloads.add(abs_path)
|
||||
|
||||
os.makedirs(download_path.parent, exist_ok=True)
|
||||
download_path.touch()
|
||||
before_download()
|
||||
self.save_file(download_path, download_fn())
|
||||
|
||||
# Move the file to its cache download location.
|
||||
os.makedirs(abs_path.parent, exist_ok=True)
|
||||
shutil.move(download_path, abs_path)
|
||||
|
||||
with self.download_set_lock:
|
||||
self.current_downloads.remove(abs_path)
|
||||
|
||||
return str(abs_path)
|
||||
|
||||
def delete_cache(self, relative_path: Union[Path, str]):
|
||||
@@ -160,13 +176,14 @@ class CacheManager(metaclass=Singleton):
|
||||
|
||||
def get_playlists(
|
||||
self,
|
||||
before_download: Callable[[], None],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> Future:
|
||||
def do_get_playlists() -> List[Playlist]:
|
||||
if not self.playlists or force:
|
||||
before_download()
|
||||
self.playlists = self.server.get_playlists().playlist
|
||||
with self.cache_lock:
|
||||
self.playlists = self.server.get_playlists().playlist
|
||||
self.save_cache_info()
|
||||
return self.playlists
|
||||
|
||||
@@ -175,18 +192,20 @@ class CacheManager(metaclass=Singleton):
|
||||
def get_playlist(
|
||||
self,
|
||||
playlist_id: int,
|
||||
before_download: Callable[[], None],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> Future:
|
||||
def do_get_playlist() -> PlaylistWithSongs:
|
||||
if not self.playlist_details.get(playlist_id) or force:
|
||||
before_download()
|
||||
playlist = self.server.get_playlist(playlist_id)
|
||||
self.playlist_details[playlist_id] = playlist
|
||||
with self.cache_lock:
|
||||
self.playlist_details[playlist_id] = playlist
|
||||
|
||||
# Playlists also have song details, so save that as well.
|
||||
for song in (playlist.entry or []):
|
||||
self.song_details[song.id] = song
|
||||
# Playlists also have song details, so save that as
|
||||
# well.
|
||||
for song in (playlist.entry or []):
|
||||
self.song_details[song.id] = song
|
||||
|
||||
self.save_cache_info()
|
||||
|
||||
@@ -204,7 +223,7 @@ class CacheManager(metaclass=Singleton):
|
||||
def get_cover_art_filename(
|
||||
self,
|
||||
id: str,
|
||||
before_download: Callable[[], None],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
size: Union[str, int] = 200,
|
||||
force: bool = False,
|
||||
) -> Future:
|
||||
@@ -221,13 +240,15 @@ class CacheManager(metaclass=Singleton):
|
||||
def get_song_details(
|
||||
self,
|
||||
song_id: int,
|
||||
before_download: Callable[[], None],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> Future:
|
||||
def do_get_song_details() -> Child:
|
||||
if not self.song_details.get(song_id) or force:
|
||||
before_download()
|
||||
self.song_details[song_id] = self.server.get_song(song_id)
|
||||
with self.cache_lock:
|
||||
self.song_details[song_id] = self.server.get_song(
|
||||
song_id)
|
||||
self.save_cache_info()
|
||||
|
||||
return self.song_details[song_id]
|
||||
@@ -237,28 +258,28 @@ class CacheManager(metaclass=Singleton):
|
||||
def get_song_filename(
|
||||
self,
|
||||
song: Child,
|
||||
before_download: Callable[[], None],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> Future:
|
||||
def do_get_song_filename() -> str:
|
||||
return self.return_cache_or_download(
|
||||
song_filename = self.return_cache_or_download(
|
||||
song.path,
|
||||
lambda: self.server.download(song.id),
|
||||
before_download=before_download,
|
||||
force=force,
|
||||
)
|
||||
return song_filename
|
||||
|
||||
return CacheManager.executor.submit(do_get_song_filename)
|
||||
|
||||
def get_cached_status(self, song: Child) -> SongCacheStatus:
|
||||
cache_path = self.calculate_abs_path(song.path)
|
||||
download_path = self.calculate_download_path(song.path)
|
||||
if cache_path.exists():
|
||||
if cache_path in self.permanently_cached_paths:
|
||||
return SongCacheStatus.PERMANENTLY_CACHED
|
||||
else:
|
||||
return SongCacheStatus.CACHED
|
||||
elif download_path.exists():
|
||||
elif cache_path in self.current_downloads:
|
||||
return SongCacheStatus.DOWNLOADING
|
||||
else:
|
||||
return SongCacheStatus.NOT_CACHED
|
||||
|
@@ -79,7 +79,7 @@ class Server:
|
||||
|
||||
def _post(self, url, **params):
|
||||
params = {**self._get_params(), **params}
|
||||
print(f'post: {url} params: {params}')
|
||||
print(f'[START] post: {url}')
|
||||
|
||||
# Deal with datetime parameters (convert to milliseconds since 1970)
|
||||
for k, v in params.items():
|
||||
@@ -91,6 +91,7 @@ class Server:
|
||||
if result.status_code != 200:
|
||||
raise Exception(f'Fail! {result.status_code}')
|
||||
|
||||
print(f'[FINISH] post: {url}')
|
||||
return result
|
||||
|
||||
def _post_json(
|
||||
@@ -146,6 +147,7 @@ class Server:
|
||||
return result.iter_content(chunk_size=1024)
|
||||
|
||||
def _download(self, url, **params) -> bytes:
|
||||
print('download', url)
|
||||
return self._post(url, **params).content
|
||||
|
||||
def ping(self) -> Response:
|
||||
|
@@ -37,7 +37,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.pack_end(up_next_volume)
|
||||
|
||||
def update(self, state: ApplicationState):
|
||||
if state.current_song is not None:
|
||||
if hasattr(state, 'current_song') and state.current_song is not None:
|
||||
self.update_scrubber(state.song_progress,
|
||||
state.current_song.duration)
|
||||
|
||||
@@ -153,6 +153,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
@util.async_callback(
|
||||
lambda *k, **v: CacheManager.get_cover_art_filename(*k, **v),
|
||||
before_download=lambda self: print('set loading here'),
|
||||
on_failure=lambda self, e: print('stop loading here'),
|
||||
)
|
||||
def update_cover_art(self, cover_art_filename):
|
||||
self.album_art.set_from_file(cover_art_filename)
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import gi
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, OrderedDict
|
||||
|
||||
from deepdiff import DeepDiff
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gio, Gtk, Pango, GObject, GLib
|
||||
|
||||
@@ -165,6 +168,11 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
self.on_playlist_edit_button_click)
|
||||
action_button_box.pack_end(playlist_edit_button, False, False, 5)
|
||||
|
||||
download_all_button = util.button_with_icon('folder-download-symbolic')
|
||||
download_all_button.connect(
|
||||
'clicked', self.on_playlist_list_download_all_button_click)
|
||||
action_button_box.pack_end(download_all_button, False, False, 5)
|
||||
|
||||
playlist_details_box.pack_start(action_button_box, False, False, 5)
|
||||
|
||||
playlist_details_box.pack_start(Gtk.Box(), True, False, 0)
|
||||
@@ -269,8 +277,7 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
playlist_id = self.playlist_map[selected.get_index()].id
|
||||
dialog = EditPlaylistDialog(
|
||||
self.get_toplevel(),
|
||||
CacheManager.get_playlist(playlist_id,
|
||||
before_download=lambda: None).result())
|
||||
CacheManager.get_playlist(playlist_id).result())
|
||||
|
||||
def on_delete_playlist(e):
|
||||
CacheManager.delete_playlist(playlist_id)
|
||||
@@ -290,6 +297,28 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
self.update_playlist_view(playlist_id, force=True)
|
||||
dialog.destroy()
|
||||
|
||||
def on_playlist_list_download_all_button_click(self, button):
|
||||
# TODO make this rate-limited so that it doesn't overwhelm the thread
|
||||
# pool.
|
||||
playlist = self.playlist_map[
|
||||
self.playlist_list.get_selected_row().get_index()]
|
||||
|
||||
def do_download_song(song: Child):
|
||||
song_filename_future = CacheManager.get_song_filename(
|
||||
song,
|
||||
before_download=lambda: self.update_playlist_view(playlist.id),
|
||||
)
|
||||
|
||||
def on_song_download_complete(f):
|
||||
self.update_playlist_view(playlist)
|
||||
|
||||
song_filename_future.add_done_callback(on_song_download_complete)
|
||||
|
||||
for song in self.playlist_song_model:
|
||||
song_details_future = CacheManager.get_song_details(song[-1])
|
||||
song_details_future.add_done_callback(
|
||||
lambda f: do_download_song(f.result()), )
|
||||
|
||||
def on_view_refresh_click(self, button):
|
||||
playlist = self.playlist_map[
|
||||
self.playlist_list.get_selected_row().get_index()]
|
||||
@@ -402,25 +431,47 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
self.playlist_comment.hide()
|
||||
self.playlist_stats.set_markup(self.format_stats(playlist))
|
||||
|
||||
# Update the song list model
|
||||
# TODO don't do this. it clears out the list an refreshes it which is
|
||||
# not what we want in most cases. Need to do some diffing.
|
||||
self.playlist_song_model.clear()
|
||||
for song in (playlist.entry or []):
|
||||
cache_icon = {
|
||||
SongCacheStatus.NOT_CACHED: '',
|
||||
SongCacheStatus.CACHED: 'folder-download-symbolic',
|
||||
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
|
||||
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
|
||||
}
|
||||
self.playlist_song_model.append([
|
||||
cache_icon[CacheManager.get_cached_status(song)],
|
||||
song.title,
|
||||
song.album,
|
||||
song.artist,
|
||||
util.format_song_duration(song.duration),
|
||||
song.id,
|
||||
])
|
||||
# Update the song list model. This requires some fancy diffing to
|
||||
# update the list.
|
||||
old_model = [row[:] for row in self.playlist_song_model]
|
||||
|
||||
cache_icon = {
|
||||
SongCacheStatus.NOT_CACHED: '',
|
||||
SongCacheStatus.CACHED: 'folder-download-symbolic',
|
||||
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
|
||||
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
|
||||
}
|
||||
new_model = [[
|
||||
cache_icon[CacheManager.get_cached_status(song)],
|
||||
song.title,
|
||||
song.album,
|
||||
song.artist,
|
||||
util.format_song_duration(song.duration),
|
||||
song.id,
|
||||
] for song in (playlist.entry or [])]
|
||||
|
||||
# Diff the lists to determine what needs to be changed.
|
||||
diff = DeepDiff(old_model, new_model)
|
||||
changed = diff.get('values_changed', {})
|
||||
added = diff.get('iterable_item_added', {})
|
||||
removed = diff.get('iterable_item_removed', {})
|
||||
|
||||
def parse_location(location):
|
||||
match = re.match(r'root\[(\d*)\](?:\[(\d*)\])?', location)
|
||||
return tuple(map(int,
|
||||
(g for g in match.groups() if g is not None)))
|
||||
|
||||
for edit_location, diff in changed.items():
|
||||
idx, field = parse_location(edit_location)
|
||||
self.playlist_song_model[idx][field] = diff['new_value']
|
||||
|
||||
for add_location, value in added.items():
|
||||
self.playlist_song_model.append(value)
|
||||
|
||||
for remove_location, value in reversed(list(removed.items())):
|
||||
remove_at = parse_location(remove_location)[0]
|
||||
del self.playlist_song_model[remove_at]
|
||||
|
||||
self.set_playlist_view_loading(False)
|
||||
|
||||
@util.async_callback(
|
||||
@@ -442,7 +493,7 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
lines = [
|
||||
' • '.join([
|
||||
f'Created by {playlist.owner} on {created_date}',
|
||||
f"{'Not v' if not playlist.public else 'V'}isible with others",
|
||||
f"{'Not v' if not playlist.public else 'V'}isible to others",
|
||||
]),
|
||||
' • '.join([
|
||||
'{} {}'.format(playlist.songCount,
|
||||
|
Reference in New Issue
Block a user