Bug fixes; diffing of the playlist list

This commit is contained in:
Sumner Evans
2019-06-29 20:44:23 -06:00
parent f0fae9692e
commit c11d6759b6
6 changed files with 167 additions and 77 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,7 @@ setup(
install_requires=[
'python-dateutil',
'python-mpv',
'deepdiff',
'requests',
'pyyaml',
'gobject',