Merge branch 'serve-local-files'
This commit is contained in:
@@ -146,6 +146,35 @@ class LibremsonicApp(Gtk.Application):
|
||||
# it exists.
|
||||
self.state.load()
|
||||
|
||||
self.last_play_queue_update = 0
|
||||
|
||||
def time_observer(value):
|
||||
self.state.song_progress = value
|
||||
GLib.idle_add(
|
||||
self.window.player_controls.update_scrubber,
|
||||
self.state.song_progress,
|
||||
self.state.current_song.duration,
|
||||
)
|
||||
if not value:
|
||||
self.last_play_queue_update = 0
|
||||
elif self.last_play_queue_update + 15 <= value:
|
||||
self.save_play_queue()
|
||||
|
||||
def on_track_end():
|
||||
GLib.idle_add(self.on_next_track)
|
||||
|
||||
self.mpv_player = MPVPlayer(
|
||||
time_observer,
|
||||
on_track_end,
|
||||
self.state.config,
|
||||
)
|
||||
self.chromecast_player = ChromecastPlayer(
|
||||
time_observer,
|
||||
on_track_end,
|
||||
self.state.config,
|
||||
)
|
||||
self.player = self.mpv_player
|
||||
|
||||
# If there is no current server, show the dialog to select a server.
|
||||
if (self.state.config.current_server is None
|
||||
or self.state.config.current_server < 0):
|
||||
@@ -181,6 +210,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.play_song(self.state.play_queue[current_idx + 1], reset=True)
|
||||
|
||||
def on_prev_track(self, action, params):
|
||||
# TODO there is a bug where you can't go back multiple songs fast
|
||||
current_idx = self.state.play_queue.index(self.state.current_song.id)
|
||||
# Go back to the beginning of the song if we are past 5 seconds.
|
||||
# Otherwise, go to the previous song.
|
||||
@@ -297,6 +327,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
return True
|
||||
|
||||
def on_app_shutdown(self, app):
|
||||
CacheManager.should_exit = True
|
||||
self.player.pause()
|
||||
self.chromecast_player.shutdown()
|
||||
self.mpv_player.shutdown()
|
||||
@@ -320,6 +351,9 @@ class LibremsonicApp(Gtk.Application):
|
||||
def update_play_state_from_server(self):
|
||||
# TODO make this non-blocking eventually (need to make everything in
|
||||
# loading state)
|
||||
self.player.pause()
|
||||
self.state.playing = False
|
||||
|
||||
play_queue = CacheManager.get_play_queue()
|
||||
self.state.play_queue = [s.id for s in play_queue.entry]
|
||||
self.state.song_progress = play_queue.position / 1000
|
||||
@@ -327,6 +361,8 @@ class LibremsonicApp(Gtk.Application):
|
||||
current_song_idx = self.state.play_queue.index(str(play_queue.current))
|
||||
self.state.current_song = play_queue.entry[current_song_idx]
|
||||
|
||||
self.player.reset()
|
||||
|
||||
self.update_window()
|
||||
|
||||
def play_song(
|
||||
@@ -339,10 +375,10 @@ class LibremsonicApp(Gtk.Application):
|
||||
# Do this the old fashioned way so that we can have access to ``reset``
|
||||
# in the callback.
|
||||
def do_play_song(song: Child):
|
||||
# TODO force stream for now and force mp3 while getting chromecast working.
|
||||
# TODO force mp3 while getting chromecast working.
|
||||
uri, stream = CacheManager.get_song_filename_or_stream(
|
||||
song,
|
||||
force_stream=True,
|
||||
force_stream=(self.player == self.chromecast_player),
|
||||
format='mp3',
|
||||
)
|
||||
|
||||
@@ -353,6 +389,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
# Prevent it from doing the thing where it continually loads
|
||||
# songs when it has to download.
|
||||
if reset:
|
||||
self.player.reset()
|
||||
self.state.song_progress = 0
|
||||
|
||||
# If streaming, also download the song.
|
||||
|
@@ -4,7 +4,8 @@ import threading
|
||||
import shutil
|
||||
import json
|
||||
import hashlib
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections import defaultdict
|
||||
from time import sleep
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from enum import EnumMeta, Enum
|
||||
@@ -68,6 +69,7 @@ class SongCacheStatus(Enum):
|
||||
|
||||
class CacheManager(metaclass=Singleton):
|
||||
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50)
|
||||
should_exit: bool = False
|
||||
|
||||
class CacheEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
@@ -94,7 +96,6 @@ class CacheManager(metaclass=Singleton):
|
||||
server: Server
|
||||
browse_by_tags: bool
|
||||
|
||||
download_move_lock = threading.Lock()
|
||||
download_set_lock = threading.Lock()
|
||||
current_downloads: Set[Path] = set()
|
||||
|
||||
@@ -201,25 +202,39 @@ class CacheManager(metaclass=Singleton):
|
||||
abs_path = self.calculate_abs_path(relative_path)
|
||||
download_path = self.calculate_download_path(relative_path)
|
||||
if not abs_path.exists() or force:
|
||||
print(abs_path, 'not found. Downloading...')
|
||||
|
||||
resource_downloading = False
|
||||
with self.download_set_lock:
|
||||
if abs_path in self.current_downloads:
|
||||
resource_downloading = True
|
||||
|
||||
self.current_downloads.add(abs_path)
|
||||
|
||||
os.makedirs(download_path.parent, exist_ok=True)
|
||||
before_download()
|
||||
self.save_file(download_path, download_fn())
|
||||
if resource_downloading:
|
||||
print(abs_path, 'already being downloaded.')
|
||||
# The resource is already being downloaded. Busy loop until
|
||||
# it has completed. Then, just return the path to the
|
||||
# resource.
|
||||
while abs_path in self.current_downloads:
|
||||
sleep(0.5)
|
||||
|
||||
# Move the file to its cache download location. We need a lock
|
||||
# here just in case we fired two downloads of the same asset
|
||||
# for some reason.
|
||||
with self.download_move_lock:
|
||||
return str(abs_path)
|
||||
|
||||
else:
|
||||
print(abs_path, 'not found. Downloading...')
|
||||
|
||||
os.makedirs(download_path.parent, exist_ok=True)
|
||||
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)
|
||||
if download_path.exists():
|
||||
shutil.move(download_path, abs_path)
|
||||
|
||||
with self.download_set_lock:
|
||||
self.current_downloads.discard(abs_path)
|
||||
with self.download_set_lock:
|
||||
self.current_downloads.discard(abs_path)
|
||||
|
||||
print(abs_path, 'downloaded. Returning.')
|
||||
|
||||
return str(abs_path)
|
||||
|
||||
@@ -447,6 +462,9 @@ class CacheManager(metaclass=Singleton):
|
||||
# TODO handle application close somehow. I think we will need to
|
||||
# raise some sort of an exception, not sure.
|
||||
def do_download_song(song_id):
|
||||
if CacheManager.should_exit:
|
||||
return
|
||||
|
||||
# Do the actual download.
|
||||
song_details_future = CacheManager.get_song_details(song_id)
|
||||
song = song_details_future.result()
|
||||
|
@@ -80,7 +80,7 @@ class Server:
|
||||
|
||||
def _get(self, url, **params):
|
||||
params = {**self._get_params(), **params}
|
||||
print(f'[START] post: {url}')
|
||||
print(f'[START] get: {url}')
|
||||
|
||||
# Deal with datetime parameters (convert to milliseconds since 1970)
|
||||
for k, v in params.items():
|
||||
@@ -92,7 +92,7 @@ class Server:
|
||||
if result.status_code != 200:
|
||||
raise Exception(f'Fail! {result.status_code}')
|
||||
|
||||
print(f'[FINISH] post: {url}')
|
||||
print(f'[FINISH] get: {url}')
|
||||
return result
|
||||
|
||||
def _get_json(
|
||||
@@ -101,8 +101,8 @@ class Server:
|
||||
**params: Union[None, str, datetime, int, List[int]],
|
||||
) -> Response:
|
||||
"""
|
||||
Make a post to a *Sonic REST API. Handle all types of errors including
|
||||
*Sonic ``<error>`` responses.
|
||||
Make a get request to a *Sonic REST API. Handle all types of errors
|
||||
including *Sonic ``<error>`` responses.
|
||||
|
||||
:returns: a Response containing all of the data of the
|
||||
response, deserialized
|
||||
|
@@ -1,3 +1,7 @@
|
||||
import threading
|
||||
from urllib.parse import urlparse, quote
|
||||
import socket
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from typing import Callable, List, Any
|
||||
from time import sleep
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
@@ -5,6 +9,7 @@ from concurrent.futures import ThreadPoolExecutor, Future
|
||||
import pychromecast
|
||||
import mpv
|
||||
|
||||
from libremsonic.config import AppConfiguration
|
||||
from libremsonic.cache_manager import CacheManager
|
||||
from libremsonic.server.api_objects import Child
|
||||
|
||||
@@ -24,10 +29,12 @@ class Player:
|
||||
on_timepos_change: Callable[[float], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
config: AppConfiguration,
|
||||
):
|
||||
self.on_timepos_change = on_timepos_change
|
||||
self.on_track_end = on_track_end
|
||||
self.on_player_event = on_player_event
|
||||
self.config = config
|
||||
self._song_loaded = False
|
||||
|
||||
@property
|
||||
@@ -46,6 +53,10 @@ class Player:
|
||||
def volume(self, value):
|
||||
return self._set_volume(value)
|
||||
|
||||
def reset(self):
|
||||
raise NotImplementedError(
|
||||
'reset must be implemented by implementor of Player')
|
||||
|
||||
def play_media(self, file_or_url, progress, song):
|
||||
raise NotImplementedError(
|
||||
'play_media must be implemented by implementor of Player')
|
||||
@@ -88,23 +99,34 @@ class MPVPlayer(Player):
|
||||
super().__init__(*args)
|
||||
|
||||
self.mpv = mpv.MPV()
|
||||
self.had_progress_value = False
|
||||
self.progress_value_lock = threading.Lock()
|
||||
self.progress_value_count = 0
|
||||
|
||||
@self.mpv.property_observer('time-pos')
|
||||
def time_observer(_name, value):
|
||||
self.on_timepos_change(value)
|
||||
if value is None and self.had_progress_value:
|
||||
if value is None and self.progress_value_count > 1:
|
||||
self.on_track_end()
|
||||
self.had_progress_value = False
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count = 0
|
||||
|
||||
if value:
|
||||
self.had_progress_value = True
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count += 1
|
||||
|
||||
def _is_playing(self):
|
||||
return not self.mpv.pause
|
||||
|
||||
def reset(self):
|
||||
self._song_loaded = False
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count = 0
|
||||
|
||||
def play_media(self, file_or_url, progress, song):
|
||||
self.had_progress_value = False
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count = 0
|
||||
|
||||
self.mpv.pause = False
|
||||
self.mpv.command(
|
||||
'loadfile',
|
||||
@@ -155,6 +177,28 @@ class ChromecastPlayer(Player):
|
||||
cast_status_listener = CastStatusListener()
|
||||
media_status_listener = MediaStatusListener()
|
||||
|
||||
class ServerThread(threading.Thread):
|
||||
def __init__(self, host, port, directory):
|
||||
super().__init__()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.directory = directory
|
||||
|
||||
def generate_handler(self, directory):
|
||||
class ServerHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=directory, **kwargs)
|
||||
|
||||
return ServerHandler
|
||||
|
||||
def run(self):
|
||||
self.server = HTTPServer(
|
||||
(self.host, self.port),
|
||||
self.generate_handler(self.directory),
|
||||
)
|
||||
# TODO figure out how to make this stop when the app closes.
|
||||
self.server.serve_forever()
|
||||
|
||||
@classmethod
|
||||
def get_chromecasts(self) -> Future:
|
||||
def do_get_chromecasts():
|
||||
@@ -180,6 +224,21 @@ class ChromecastPlayer(Player):
|
||||
ChromecastPlayer.cast_status_listener.on_new_cast_status = self.on_new_cast_status
|
||||
ChromecastPlayer.media_status_listener.on_new_media_status = self.on_new_media_status
|
||||
|
||||
# Set host_ip
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
self.host_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# TODO make the port come from the app config
|
||||
self.server_thread = ChromecastPlayer.ServerThread(
|
||||
'0.0.0.0',
|
||||
8080,
|
||||
self.config.cache_location,
|
||||
)
|
||||
self.server_thread.daemon = True
|
||||
self.server_thread.start()
|
||||
|
||||
def on_new_cast_status(self, status):
|
||||
print('new cast status')
|
||||
print(status)
|
||||
@@ -239,7 +298,17 @@ class ChromecastPlayer(Player):
|
||||
def _is_playing(self):
|
||||
return self.chromecast.media_controller.status.player_is_playing
|
||||
|
||||
def reset(self):
|
||||
self._song_loaded = False
|
||||
|
||||
def play_media(self, file_or_url: str, progress: float, song: Child):
|
||||
stream_scheme = urlparse(file_or_url).scheme
|
||||
if not stream_scheme:
|
||||
# TODO make this come from the app config
|
||||
strlen = len('/home/sumner/.local/share/libremsonic/')
|
||||
file_or_url = file_or_url[strlen:]
|
||||
file_or_url = f'http://{self.host_ip}:8080/{quote(file_or_url)}'
|
||||
|
||||
cover_art_url = CacheManager.get_cover_art_url(song.id, 1000)
|
||||
self.chromecast.media_controller.play_media(
|
||||
file_or_url,
|
||||
|
Reference in New Issue
Block a user