From 75b89e5f45348c6c779acc21204042f11ea5e7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 1 Sep 2019 14:55:44 +0200 Subject: [PATCH 01/11] Basic (untested) jukebox interface based on an external command --- supysonic/config.py | 1 + supysonic/daemon/client.py | 58 ++++++++++++++++ supysonic/daemon/server.py | 8 +++ supysonic/db.py | 2 +- supysonic/jukebox.py | 132 +++++++++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 supysonic/jukebox.py diff --git a/supysonic/config.py b/supysonic/config.py index a83616d..bcb3875 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -45,6 +45,7 @@ class DefaultConfig(object): "socket": os.path.join(tempdir, "supysonic.sock"), "run_watcher": True, "wait_delay": 5, + "jukebox_command": None, "log_file": None, "log_level": "WARNING", } diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index 9ebfebc..b81d66d 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -59,6 +59,45 @@ class ScannerStartCommand(ScannerCommand): daemon.start_scan(self.__folders, self.__force) +class JukeboxCommand(DaemonCommand): + def __init__(self, action, arg): + self.__action = action + self.__arg = arg + + def apply(self, connection, daemon): + if daemon.jukebox is None: + connection.send(JukeboxResult(None)) + return + + playlist = None + if self.__action == "get": + playlist = daemon.jukebox.playlist + elif self.__action == "status": + pass + elif self.__action == "set": + daemon.jukebox.set(self.__arg) + elif self.__action == "start": + daemon.jukebox.start() + elif self.__action == "stop": + daemon.jukebox.stop() + elif self.__action == "skip": + daemon.jukebox.skip(self.__arg) + elif self.__action == "add": + daemon.jukebox.add(self.__arg) + elif self.__action == "clear": + daemon.jukebox.clear() + elif self.__action == "remove": + daemon.jukebox.remove(self.__arg) + elif self.__action == "shuffle": + daemon.jukebox.shuffle() + elif self.__action == "setGain": + daemon.jukebox.setgain(self.__arg) + + rv = JukeboxResult(daemon.jukebox) + rv.playlist = playlist + connection.send(rv) + + class DaemonCommandResult(object): pass @@ -70,6 +109,18 @@ class ScannerProgressResult(DaemonCommandResult): scanned = property(lambda self: self.__scanned) +class JukeboxResult(DaemonCommandResult): + def __init__(self, jukebox): + if jukebox is None: + self.playing = False + self.index = -1 + self.gain = 1.0 + else: + self.playing = jukebox.playing + self.index = jukebox.index + self.gain = jukebox.gain + + class DaemonClient(object): def __init__(self, address=None): self.__address = address or get_current_config().DAEMON["socket"] @@ -107,3 +158,10 @@ class DaemonClient(object): raise TypeError("Expecting list, got " + str(type(folders))) with self.__get_connection() as c: c.send(ScannerStartCommand(folders, force)) + + def jukebox_control(self, action, *args): + if not isinstance(action, strtype): + raise TypeError("Expecting string, got " + str(type(action))) + with self.__get_connection() as c: + c.send(JukeboxCommand(action, args)) + return c.recv() diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index c6d45cc..1bc7412 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -16,6 +16,7 @@ from threading import Thread, Event from .client import DaemonCommand from ..db import Folder +from ..jukebox import Jukebox from ..scanner import Scanner from ..utils import get_secret_key from ..watcher import SupysonicWatcher @@ -31,10 +32,12 @@ class Daemon(object): self.__listener = None self.__watcher = None self.__scanner = None + self.__jukebox = None self.__stopped = Event() watcher = property(lambda self: self.__watcher) scanner = property(lambda self: self.__scanner) + jukbox = property(lambda self: self.__jukebox) def __handle_connection(self, connection): cmd = connection.recv() @@ -56,6 +59,9 @@ class Daemon(object): self.__watcher = SupysonicWatcher(self.__config) self.__watcher.start() + if self.__config.DAEMON["jukebox_command"]: + self.__jukebox = Jukebox(self.__config.DAEMON["jukebox_command"]) + Thread(target=self.__listen).start() while not self.__stopped.is_set(): time.sleep(1) @@ -109,3 +115,5 @@ class Daemon(object): self.__scanner.join() if self.__watcher is not None: self.__watcher.stop() + if self.__jukebox is not None: + self.__jukebox.terminate() diff --git a/supysonic/db.py b/supysonic/db.py index d23ac53..a9f7f37 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -394,7 +394,7 @@ class User(db.Entity): commentRole=False, podcastRole=False, streamRole=True, - jukeboxRole=False, + jukeboxRole=True, shareRole=False, ) diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py new file mode 100644 index 0000000..89d0078 --- /dev/null +++ b/supysonic/jukebox.py @@ -0,0 +1,132 @@ +# coding: utf-8 +# +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2019 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import logging +import shlex +import time + +from pony.orm import select +from random import shuffle +from subprocess import Popen, DEVNULL +from threading import Thread, Event, RLock + +from .db import Track + +logger = logging.getLogger(__name__) + + +class Jukebox(object): + def __init__(self, cmd): + self.__cmd = shlex.split(cmd) + self.__playlist = [] + self.__index = -1 + + self.__thread = None + self.__lock = RLock() + self.__skip = Event() + self.__stop = Event() + + playing = property( + lambda self: self.__thread is not None and self.__thread.is_alive() + ) + index = property(lambda self: self.__index) + gain = property(lambda self: 1.0) + playlist = property(lambda self: list(self.__playlist)) + + def set(self, tracks): + self.clear() + self.add(tracks) + + def start(self): + if self.playing or not self.__playlist: + return + + self.__skip.clear() + self.__stop.clear() + self.__thread = Thread(target=self.__play_thread) + self.__thread.start() + + def stop(self): + if not self.playing: + return + + self.__stop.set() + + def skip(self, index): + if not self.playing: + return + + if index < 0 or index >= len(self.__playlist): + raise IndexError() + + with self.__lock: + self.__index = index - 1 + self.__skip.set() + + def add(self, tracks): + paths = select(t.path for t in Track if t.id in tracks) + with self.__lock: + self.__playlist += paths[:] + + def clear(self): + with self.__lock: + self.__playlist.clear() + self.__index = -1 + + def remove(self, index): + try: + with self.__lock: + self.__playlist.pop(index) + if index < self.__index: + self.__index -= 1 + except IndexError: + pass + + def shuffle(self): + with self.__lock: + shuffle(self.__playlist) + + def setgain(self, gain): + raise NotImplementedError() + + def terminate(self): + self.__stop.set() + if self.__thread is not None: + self.__thread.join() + + def __play_thread(self): + while not self.__stop.is_set(): + if self.__skip.is_set(): + proc.terminate() + proc.join() + self.__skip.clear() + + if proc is None or proc.poll() is not None: + with self.__lock: + self.__index += 1 + if self.__index >= len(self.__playlist): + break + + proc = self.__play_file() + + time.sleep(0.1) + + proc.terminate() + proc.wait() + + def __play_file(self): + path = self.__playlist[self.__index] + args = [a.replace("%path", path) for a in self.__cmd] + + logger.debug("Start playing with command %s", args) + try: + return Popen(args, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL) + except: + logger.exception("Failed running play command") + return None From 0d40ede25684f8ab81740061c8ace40f14d2dcf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 1 Sep 2019 17:07:35 +0200 Subject: [PATCH 02/11] Jukebox endpoint + some fixes --- supysonic/api/__init__.py | 1 + supysonic/api/jukebox.py | 94 ++++++++++++++++++++++++++++++++++++++ supysonic/daemon/client.py | 5 +- supysonic/daemon/server.py | 2 +- supysonic/jukebox.py | 17 ++++--- 5 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 supysonic/api/jukebox.py diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 3427acf..fc9ac3b 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -98,4 +98,5 @@ from .annotation import * from .chat import * from .search import * from .playlists import * +from .jukebox import * from .unsupported import * diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py new file mode 100644 index 0000000..5cd77d8 --- /dev/null +++ b/supysonic/api/jukebox.py @@ -0,0 +1,94 @@ +# coding: utf-8 +# +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2019 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import uuid + +from flask import current_app, request +from pony.orm import ObjectNotFound + +from ..daemon import DaemonClient +from ..daemon.exceptions import DaemonUnavailableError +from ..db import Track + +from . import api +from .exceptions import GenericError, MissingParameter + + +@api.route("/jukeboxControl.view", methods=["GET", "POST"]) +def jukebox_control(): + action = request.values["action"] + + index = request.values.get("index") + offset = request.values.get("offset") + id = request.values.getlist("id") + gain = request.values.get("gain") + + if action not in ( + "get", + "status", + "set", + "start", + "stop", + "skip", + "add", + "clear", + "remove", + "shuffle", + "setGain", + ): + raise GenericError("Unknown action") + + arg = None + if action == "set": + if not id: + arg = [] + else: + arg = [uuid.UUID(i) for i in id] + elif action == "skip": + if not index: + raise MissingParameter("index") + else: + arg = int(index) + elif action == "add": + if not id: + raise MissingParameter("id") + else: + arg = [uuid.UUID(i) for i in id] + elif action == "remove": + if not index: + raise MissingParameter("index") + else: + arg = int(index) + elif action == "setGain": + if not gain: + raise MissingParameter("gain") + else: + arg = float(gain) + + try: + status = DaemonClient(current_app.config["DAEMON"]["socket"]).jukebox_control( + action, arg + ) + except DaemonUnavailableError: + raise GenericError("Jukebox unavaliable") + + rv = dict(currentIndex=status.index, playing=status.playing, gain=status.gain) + if action == "get": + playlist = [] + for path in status.playlist: + try: + playlist.append(Track.get(path=path)) + except ObjectNotFound: + pass + rv["entry"] = [ + t.as_subsonic_child(request.user, request.client) for t in playlist + ] + return request.formatter("jukeboxPlaylist", rv) + else: + return request.formatter("jukeboxStatus", rv) diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index b81d66d..2e342a5 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -119,6 +119,7 @@ class JukeboxResult(DaemonCommandResult): self.playing = jukebox.playing self.index = jukebox.index self.gain = jukebox.gain + self.playlist = () class DaemonClient(object): @@ -159,9 +160,9 @@ class DaemonClient(object): with self.__get_connection() as c: c.send(ScannerStartCommand(folders, force)) - def jukebox_control(self, action, *args): + def jukebox_control(self, action, arg): if not isinstance(action, strtype): raise TypeError("Expecting string, got " + str(type(action))) with self.__get_connection() as c: - c.send(JukeboxCommand(action, args)) + c.send(JukeboxCommand(action, arg)) return c.recv() diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index 1bc7412..fa2d07b 100644 --- a/supysonic/daemon/server.py +++ b/supysonic/daemon/server.py @@ -37,7 +37,7 @@ class Daemon(object): watcher = property(lambda self: self.__watcher) scanner = property(lambda self: self.__scanner) - jukbox = property(lambda self: self.__jukebox) + jukebox = property(lambda self: self.__jukebox) def __handle_connection(self, connection): cmd = connection.recv() diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 89d0078..1077437 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -11,7 +11,7 @@ import logging import shlex import time -from pony.orm import select +from pony.orm import db_session, ObjectNotFound from random import shuffle from subprocess import Popen, DEVNULL from threading import Thread, Event, RLock @@ -59,20 +59,22 @@ class Jukebox(object): self.__stop.set() def skip(self, index): - if not self.playing: - return - if index < 0 or index >= len(self.__playlist): raise IndexError() with self.__lock: self.__index = index - 1 self.__skip.set() + self.start() def add(self, tracks): - paths = select(t.path for t in Track if t.id in tracks) with self.__lock: - self.__playlist += paths[:] + with db_session: + for t in tracks: + try: + self.__playlist.append(Track[t].path) + except ObjectNotFound: + pass def clear(self): with self.__lock: @@ -101,10 +103,11 @@ class Jukebox(object): self.__thread.join() def __play_thread(self): + proc = None while not self.__stop.is_set(): if self.__skip.is_set(): proc.terminate() - proc.join() + proc.wait() self.__skip.clear() if proc is None or proc.poll() is not None: From 4e69cd0551638947886223ab81e8cd5c971d5113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 1 Sep 2019 17:23:50 +0200 Subject: [PATCH 03/11] Fixed jukebox endpoint response not being in sync when skipping --- supysonic/jukebox.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 1077437..ee51500 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -25,7 +25,7 @@ class Jukebox(object): def __init__(self, cmd): self.__cmd = shlex.split(cmd) self.__playlist = [] - self.__index = -1 + self.__index = 0 self.__thread = None self.__lock = RLock() @@ -63,7 +63,7 @@ class Jukebox(object): raise IndexError() with self.__lock: - self.__index = index - 1 + self.__index = index self.__skip.set() self.start() @@ -79,7 +79,7 @@ class Jukebox(object): def clear(self): with self.__lock: self.__playlist.clear() - self.__index = -1 + self.__index = 0 def remove(self, index): try: @@ -108,9 +108,13 @@ class Jukebox(object): if self.__skip.is_set(): proc.terminate() proc.wait() + proc = None self.__skip.clear() - if proc is None or proc.poll() is not None: + if proc is None: + with self.__lock: + proc = self.__play_file() + elif proc.poll() is not None: with self.__lock: self.__index += 1 if self.__index >= len(self.__playlist): From 9de96bb53016df066feaeb984eb1cc472d82449f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 7 Sep 2019 17:01:04 +0200 Subject: [PATCH 04/11] Fix for python 2.7 --- supysonic/jukebox.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index ee51500..968b592 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -8,12 +8,13 @@ # Distributed under terms of the GNU AGPLv3 license. import logging +import os import shlex import time from pony.orm import db_session, ObjectNotFound from random import shuffle -from subprocess import Popen, DEVNULL +from subprocess import Popen from threading import Thread, Event, RLock from .db import Track @@ -27,6 +28,8 @@ class Jukebox(object): self.__playlist = [] self.__index = 0 + self.__devnull = None + self.__thread = None self.__lock = RLock() self.__skip = Event() @@ -39,6 +42,18 @@ class Jukebox(object): gain = property(lambda self: 1.0) playlist = property(lambda self: list(self.__playlist)) + # subprocess.DEVNULL doesn't exist on Python 2.7 + def _get_devnull(self): + if self.__devnull is None: + self.__devnull = os.open(os.devnull, os.O_RDWR) + return self.__devnull + + def _close_devnull(self): + if self.__devnull is None: + return + os.close(self.__devnull) + self.__devnull = None + def set(self, tracks): self.clear() self.add(tracks) @@ -126,6 +141,7 @@ class Jukebox(object): proc.terminate() proc.wait() + self._close_devnull() def __play_file(self): path = self.__playlist[self.__index] @@ -133,7 +149,12 @@ class Jukebox(object): logger.debug("Start playing with command %s", args) try: - return Popen(args, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL) + return Popen( + args, + stdin=self._get_devnull(), + stdout=self._get_devnull(), + stderr=self._get_devnull(), + ) except: logger.exception("Failed running play command") return None From ee2efec59a502a4b5ac3d07df7749813a3143027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 7 Sep 2019 17:37:04 +0200 Subject: [PATCH 05/11] Jukebox status: report (simulated) position --- supysonic/api/jukebox.py | 7 ++++++- supysonic/daemon/client.py | 2 ++ supysonic/jukebox.py | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 5cd77d8..74661ce 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -78,7 +78,12 @@ def jukebox_control(): except DaemonUnavailableError: raise GenericError("Jukebox unavaliable") - rv = dict(currentIndex=status.index, playing=status.playing, gain=status.gain) + rv = dict( + currentIndex=status.index, + playing=status.playing, + gain=status.gain, + position=status.position, + ) if action == "get": playlist = [] for path in status.playlist: diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index 2e342a5..e219bf3 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -115,10 +115,12 @@ class JukeboxResult(DaemonCommandResult): self.playing = False self.index = -1 self.gain = 1.0 + self.position = 0 else: self.playing = jukebox.playing self.index = jukebox.index self.gain = jukebox.gain + self.position = jukebox.position self.playlist = () diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 968b592..03df5b0 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -12,6 +12,7 @@ import os import shlex import time +from datetime import datetime from pony.orm import db_session, ObjectNotFound from random import shuffle from subprocess import Popen @@ -27,6 +28,7 @@ class Jukebox(object): self.__cmd = shlex.split(cmd) self.__playlist = [] self.__index = 0 + self.__start = None self.__devnull = None @@ -42,6 +44,12 @@ class Jukebox(object): gain = property(lambda self: 1.0) playlist = property(lambda self: list(self.__playlist)) + @property + def position(self): + if self.__start is None: + return 0 + return int((datetime.utcnow() - self.__start).total_seconds()) + # subprocess.DEVNULL doesn't exist on Python 2.7 def _get_devnull(self): if self.__devnull is None: @@ -79,6 +87,7 @@ class Jukebox(object): with self.__lock: self.__index = index + self.__start = None self.__skip.set() self.start() @@ -131,6 +140,7 @@ class Jukebox(object): proc = self.__play_file() elif proc.poll() is not None: with self.__lock: + self.__start = None self.__index += 1 if self.__index >= len(self.__playlist): break @@ -142,6 +152,7 @@ class Jukebox(object): proc.terminate() proc.wait() self._close_devnull() + self.__start = None def __play_file(self): path = self.__playlist[self.__index] @@ -149,6 +160,7 @@ class Jukebox(object): logger.debug("Start playing with command %s", args) try: + self.__start = datetime.utcnow() return Popen( args, stdin=self._get_devnull(), From 3b5885dde4dd1768302d8495c5265b43416c4c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Sep 2019 15:41:20 +0200 Subject: [PATCH 06/11] Skipping within tracks --- supysonic/api/jukebox.py | 20 ++++++++-------- supysonic/daemon/client.py | 49 +++++++++++++++++++++----------------- supysonic/jukebox.py | 27 ++++++++++++++------- 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index 74661ce..b3e8168 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -44,36 +44,36 @@ def jukebox_control(): ): raise GenericError("Unknown action") - arg = None + args = () if action == "set": - if not id: - arg = [] - else: - arg = [uuid.UUID(i) for i in id] + if id: + args = [uuid.UUID(i) for i in id] elif action == "skip": if not index: raise MissingParameter("index") + if offset: + args = (int(index), int(offset)) else: - arg = int(index) + args = (int(index), 0) elif action == "add": if not id: raise MissingParameter("id") else: - arg = [uuid.UUID(i) for i in id] + args = [uuid.UUID(i) for i in id] elif action == "remove": if not index: raise MissingParameter("index") else: - arg = int(index) + args = (int(index),) elif action == "setGain": if not gain: raise MissingParameter("gain") else: - arg = float(gain) + args = (float(gain),) try: status = DaemonClient(current_app.config["DAEMON"]["socket"]).jukebox_control( - action, arg + action, *args ) except DaemonUnavailableError: raise GenericError("Jukebox unavaliable") diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index e219bf3..4688865 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -60,9 +60,9 @@ class ScannerStartCommand(ScannerCommand): class JukeboxCommand(DaemonCommand): - def __init__(self, action, arg): + def __init__(self, action, args): self.__action = action - self.__arg = arg + self.__args = args def apply(self, connection, daemon): if daemon.jukebox is None: @@ -74,24 +74,29 @@ class JukeboxCommand(DaemonCommand): playlist = daemon.jukebox.playlist elif self.__action == "status": pass - elif self.__action == "set": - daemon.jukebox.set(self.__arg) - elif self.__action == "start": - daemon.jukebox.start() - elif self.__action == "stop": - daemon.jukebox.stop() - elif self.__action == "skip": - daemon.jukebox.skip(self.__arg) - elif self.__action == "add": - daemon.jukebox.add(self.__arg) - elif self.__action == "clear": - daemon.jukebox.clear() - elif self.__action == "remove": - daemon.jukebox.remove(self.__arg) - elif self.__action == "shuffle": - daemon.jukebox.shuffle() - elif self.__action == "setGain": - daemon.jukebox.setgain(self.__arg) + else: + func = None + + if self.__action == "set": + func = daemon.jukebox.set + elif self.__action == "start": + func = daemon.jukebox.start + elif self.__action == "stop": + func = daemon.jukebox.stop + elif self.__action == "skip": + func = daemon.jukebox.skip + elif self.__action == "add": + func = daemon.jukebox.add + elif self.__action == "clear": + func = daemon.jukebox.clear + elif self.__action == "remove": + func = daemon.jukebox.remove + elif self.__action == "shuffle": + func = daemon.jukebox.shuffle + elif self.__action == "setGain": + func = daemon.jukebox.setgain + + func(*self.__args) rv = JukeboxResult(daemon.jukebox) rv.playlist = playlist @@ -162,9 +167,9 @@ class DaemonClient(object): with self.__get_connection() as c: c.send(ScannerStartCommand(folders, force)) - def jukebox_control(self, action, arg): + def jukebox_control(self, action, *args): if not isinstance(action, strtype): raise TypeError("Expecting string, got " + str(type(action))) with self.__get_connection() as c: - c.send(JukeboxCommand(action, arg)) + c.send(JukeboxCommand(action, args)) return c.recv() diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 03df5b0..2360bb6 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -12,7 +12,7 @@ import os import shlex import time -from datetime import datetime +from datetime import datetime, timedelta from pony.orm import db_session, ObjectNotFound from random import shuffle from subprocess import Popen @@ -28,6 +28,7 @@ class Jukebox(object): self.__cmd = shlex.split(cmd) self.__playlist = [] self.__index = 0 + self.__offset = 0 self.__start = None self.__devnull = None @@ -62,9 +63,9 @@ class Jukebox(object): os.close(self.__devnull) self.__devnull = None - def set(self, tracks): + def set(self, *tracks): self.clear() - self.add(tracks) + self.add(*tracks) def start(self): if self.playing or not self.__playlist: @@ -72,6 +73,7 @@ class Jukebox(object): self.__skip.clear() self.__stop.clear() + self.__offset = 0 self.__thread = Thread(target=self.__play_thread) self.__thread.start() @@ -81,17 +83,20 @@ class Jukebox(object): self.__stop.set() - def skip(self, index): + def skip(self, index, offset): if index < 0 or index >= len(self.__playlist): raise IndexError() + if offset < 0: + raise ValueError() with self.__lock: self.__index = index - self.__start = None + self.__offset = offset + self.__start = datetime.utcnow() - timedelta(seconds=offset) self.__skip.set() self.start() - def add(self, tracks): + def add(self, *tracks): with self.__lock: with db_session: for t in tracks: @@ -104,6 +109,7 @@ class Jukebox(object): with self.__lock: self.__playlist.clear() self.__index = 0 + self.__offset = 0 def remove(self, index): try: @@ -156,11 +162,16 @@ class Jukebox(object): def __play_file(self): path = self.__playlist[self.__index] - args = [a.replace("%path", path) for a in self.__cmd] + args = [ + a.replace("%path", path).replace("%offset", str(self.__offset)) + for a in self.__cmd + ] + + self.__start = datetime.utcnow() - timedelta(seconds=self.__offset) + self.__offset = 0 logger.debug("Start playing with command %s", args) try: - self.__start = datetime.utcnow() return Popen( args, stdin=self._get_devnull(), From 296fbfc3f4c757d4e6d2cf8ff784a4c699c73d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Sep 2019 15:50:31 +0200 Subject: [PATCH 07/11] Ignore setGain commands rather than erroring --- supysonic/jukebox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py index 2360bb6..5458b67 100644 --- a/supysonic/jukebox.py +++ b/supysonic/jukebox.py @@ -125,7 +125,7 @@ class Jukebox(object): shuffle(self.__playlist) def setgain(self, gain): - raise NotImplementedError() + pass def terminate(self): self.__stop.set() From 0cc9877bd9cc4eb1e1ced7b63e712e3b2c83bdc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 27 Oct 2019 17:07:03 +0100 Subject: [PATCH 08/11] Add jukebox role --- docs/cli.md | 12 ++-- docs/man/supysonic-cli-user.rst | 22 +++++--- supysonic/api/jukebox.py | 5 +- supysonic/cli.py | 55 ++++++++++++------- supysonic/db.py | 8 ++- supysonic/schema/migration/mysql/20190921.sql | 1 + .../schema/migration/postgres/20190921.sql | 1 + .../schema/migration/sqlite/20190921.sql | 1 + supysonic/schema/mysql.sql | 1 + supysonic/schema/postgres.sql | 1 + supysonic/schema/sqlite.sql | 1 + 11 files changed, 72 insertions(+), 36 deletions(-) create mode 100644 supysonic/schema/migration/mysql/20190921.sql create mode 100644 supysonic/schema/migration/postgres/20190921.sql create mode 100644 supysonic/schema/migration/sqlite/20190921.sql diff --git a/docs/cli.md b/docs/cli.md index 2ac3031..7e5ee36 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,24 +27,26 @@ Arguments: ``` Usage: - supysonic-cli user add [-a] [-p ] [-e ] + supysonic-cli user add [-p ] [-e ] supysonic-cli user delete supysonic-cli user changepass supysonic-cli user list - supysonic-cli user setadmin [--off] + supysonic-cli user setroles [-a|-A] [-j|-J] Arguments: add Add a new user delete Delete the user changepass Change the user's password list List all the users - setadmin Give admin rights to the user + setroles Give or remove rights to the user Options: - -a --admin Create the user with admin rights -p --password Specify the user's password -e --email Specify the user's email - --off Revoke the admin rights if present + -a --noadmin Revoke admin rights + -A --admin Grant admin rights + -j --nojukebox Revoke jukebox rights + -J --jukebox Grant jukebox rights ``` ## Folder management commands diff --git a/docs/man/supysonic-cli-user.rst b/docs/man/supysonic-cli-user.rst index 26a0d84..9ac39d7 100644 --- a/docs/man/supysonic-cli-user.rst +++ b/docs/man/supysonic-cli-user.rst @@ -13,11 +13,11 @@ Supysonic user management commands Synopsis ======== -| supysonic-cli user **add** [-a] [-p ] [-e ] +| supysonic-cli user **add** [-p ] [-e ] | supysonic-cli user **delete** | supysonic-cli user **changepass** | supysonic-cli user **list** -| supysonic-cli user **setadmin** [--off] +| supysonic-cli user **setroles** [-a|-A] [-j|-J] Arguments ========= @@ -26,22 +26,28 @@ Arguments | **delete** Delete the user | **changepass** Change the user's password | **list** List all the users -| **setadmin** Give admin rights to the user +| **setroles** Give or remove rights to the user Options ======= -| **-a** | **--admin** -|     Create the user with admin rights - | **-p** | **--password** ** |     Specify the user's password | **-e** | **--email** ** |     Specify the user's email -| **--off** -|     Revoke the admin rights if present +| **-a** | **--noadmin** +|     Revoke admin rights + +| **-A** | **--admin** +|     Grant admin rights + +| **-j** | **--nojukebox** +|     Revoke jukebox rights + +| **-J** | **--jukebox** +|     Grant jukebox rights Examples ======== diff --git a/supysonic/api/jukebox.py b/supysonic/api/jukebox.py index b3e8168..02fcef4 100644 --- a/supysonic/api/jukebox.py +++ b/supysonic/api/jukebox.py @@ -17,11 +17,14 @@ from ..daemon.exceptions import DaemonUnavailableError from ..db import Track from . import api -from .exceptions import GenericError, MissingParameter +from .exceptions import GenericError, MissingParameter, Forbidden @api.route("/jukeboxControl.view", methods=["GET", "POST"]) def jukebox_control(): + if not request.user.jukebox and not request.user.admin: + raise Forbidden() + action = request.values["action"] index = request.values.get("index") diff --git a/supysonic/cli.py b/supysonic/cli.py index a75733d..af1c72a 100755 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -313,9 +313,6 @@ class SupysonicCLI(cmd.Cmd): "add", help="Adds a user", add_help=False ) user_add_parser.add_argument("name", help="Name/login of the user to add") - user_add_parser.add_argument( - "-a", "--admin", action="store_true", help="Give admin rights to the new user" - ) user_add_parser.add_argument( "-p", "--password", help="Specifies the user's password" ) @@ -326,16 +323,25 @@ class SupysonicCLI(cmd.Cmd): "delete", help="Deletes a user", add_help=False ) user_del_parser.add_argument("name", help="Name/login of the user to delete") - user_admin_parser = user_subparsers.add_parser( - "setadmin", help="Enable/disable admin rights for a user", add_help=False + user_roles_parser = user_subparsers.add_parser( + "setroles", help="Enable/disable rights for a user", add_help=False ) - user_admin_parser.add_argument( + user_roles_parser.add_argument( "name", help="Name/login of the user to grant/revoke admin rights" ) - user_admin_parser.add_argument( - "--off", - action="store_true", - help="Revoke admin rights if present, grant them otherwise", + user_roles_admin_group = user_roles_parser.add_mutually_exclusive_group() + user_roles_admin_group.add_argument( + "-A", "--admin", action="store_true", help="Grant admin rights" + ) + user_roles_admin_group.add_argument( + "-a", "--noadmin", action="store_true", help="Revoke admin rights" + ) + user_roles_jukebox_group = user_roles_parser.add_mutually_exclusive_group() + user_roles_jukebox_group.add_argument( + "-J", "--jukebox", action="store_true", help="Grant jukebox rights" + ) + user_roles_jukebox_group.add_argument( + "-j", "--nojukebox", action="store_true", help="Revoke jukebox rights" ) user_pass_parser = user_subparsers.add_parser( "changepass", help="Changes a user's password", add_help=False @@ -347,10 +353,13 @@ class SupysonicCLI(cmd.Cmd): @db_session def user_list(self): - self.write_line("Name\t\tAdmin\tEmail\n----\t\t-----\t-----") + self.write_line("Name\t\tAdmin\tJukebox\tEmail") + self.write_line("----\t\t-----\t-------\t-----") self.write_line( "\n".join( - "{0: <16}{1}\t{2}".format(u.name, "*" if u.admin else "", u.mail) + "{0: <16}{1}\t{2}\t{3}".format( + u.name, "*" if u.admin else "", "*" if u.jukebox else "", u.mail + ) for u in User.select() ) ) @@ -363,11 +372,11 @@ class SupysonicCLI(cmd.Cmd): return password @db_session - def user_add(self, name, admin, password, email): + def user_add(self, name, password, email): try: if not password: password = self._ask_password() # pragma: nocover - UserManager.add(name, password, email, admin) + UserManager.add(name, password, email, False) except ValueError as e: self.write_error_line(str(e)) @@ -380,15 +389,23 @@ class SupysonicCLI(cmd.Cmd): self.write_error_line(str(e)) @db_session - def user_setadmin(self, name, off): + def user_setroles(self, name, admin, noadmin, jukebox, nojukebox): user = User.get(name=name) if user is None: self.write_error_line("No such user") else: - user.admin = not off - self.write_line( - "{0} '{1}' admin rights".format("Revoked" if off else "Granted", name) - ) + if admin: + user.admin = True + self.write_line("Granted '{0}' admin rights".format(name)) + elif noadmin: + user.admin = False + self.write_line("Revoked '{0}' admin rights".format(name)) + if jukebox: + user.jukebox = True + self.write_line("Granted '{0}' jukebox rights".format(name)) + elif nojukebox: + user.jukebox = False + self.write_line("Revoked '{0}' jukebox rights".format(name)) @db_session def user_changepass(self, name, password): diff --git a/supysonic/db.py b/supysonic/db.py index a9f7f37..96f5a11 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -29,7 +29,7 @@ try: except ImportError: from urlparse import urlparse, parse_qsl -SCHEMA_VERSION = "20190518" +SCHEMA_VERSION = "20190921" def now(): @@ -360,7 +360,10 @@ class User(db.Entity): mail = Optional(str) password = Required(str, 40) salt = Required(str, 6) + admin = Required(bool, default=False) + jukebox = Required(bool, default=False) + lastfm_session = Optional(str, 32, nullable=True) lastfm_status = Required( bool, default=True @@ -394,7 +397,7 @@ class User(db.Entity): commentRole=False, podcastRole=False, streamRole=True, - jukeboxRole=True, + jukeboxRole=self.admin or self.jukebox, shareRole=False, ) @@ -651,7 +654,6 @@ def init_database(database_uri): db.generate_mapping(check_tables=False) - def release_database(): metadb.disconnect() db.disconnect() diff --git a/supysonic/schema/migration/mysql/20190921.sql b/supysonic/schema/migration/mysql/20190921.sql new file mode 100644 index 0000000..7499c6a --- /dev/null +++ b/supysonic/schema/migration/mysql/20190921.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD jukebox BOOLEAN DEFAULT false NOT NULL AFTER admin; diff --git a/supysonic/schema/migration/postgres/20190921.sql b/supysonic/schema/migration/postgres/20190921.sql new file mode 100644 index 0000000..22596c0 --- /dev/null +++ b/supysonic/schema/migration/postgres/20190921.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD jukebox BOOLEAN DEFAULT false NOT NULL; diff --git a/supysonic/schema/migration/sqlite/20190921.sql b/supysonic/schema/migration/sqlite/20190921.sql new file mode 100644 index 0000000..0fc4cca --- /dev/null +++ b/supysonic/schema/migration/sqlite/20190921.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD jukebox BOOLEAN DEFAULT false NOT NULL; diff --git a/supysonic/schema/mysql.sql b/supysonic/schema/mysql.sql index 864b2a8..afdafe7 100644 --- a/supysonic/schema/mysql.sql +++ b/supysonic/schema/mysql.sql @@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS user ( password CHAR(40) NOT NULL, salt CHAR(6) NOT NULL, admin BOOLEAN NOT NULL, + jukebox BOOLEAN NOT NULL, lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id BINARY(16) REFERENCES track, diff --git a/supysonic/schema/postgres.sql b/supysonic/schema/postgres.sql index 95c9e94..c003fd0 100644 --- a/supysonic/schema/postgres.sql +++ b/supysonic/schema/postgres.sql @@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS "user" ( password CHAR(40) NOT NULL, salt CHAR(6) NOT NULL, admin BOOLEAN NOT NULL, + jukebox BOOLEAN NOT NULL, lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id UUID REFERENCES track, diff --git a/supysonic/schema/sqlite.sql b/supysonic/schema/sqlite.sql index 23a7ff8..72178ce 100644 --- a/supysonic/schema/sqlite.sql +++ b/supysonic/schema/sqlite.sql @@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS user ( password CHAR(40) NOT NULL, salt CHAR(6) NOT NULL, admin BOOLEAN NOT NULL, + jukebox BOOLEAN NOT NULL, lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id CHAR(36) REFERENCES track, From d3bce834743cd3724b133fbfb684e9ffc873deea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 23 Nov 2019 15:08:18 +0100 Subject: [PATCH 09/11] Docs update --- README.md | 5 ++++ config.sample | 3 +++ docs/api.md | 14 +++++------ docs/configuration.md | 6 +++++ docs/jukebox.md | 43 ++++++++++++++++++++++++++++++++ tests/assets/folder/silence.mp3 | Bin 85812 -> 85812 bytes 6 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 docs/jukebox.md diff --git a/README.md b/README.md index af435ee..66aaac7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Current supported features are: * cover arts (as image files in the same folder as music files) * starred tracks/albums and ratings * [Last.FM][lastfm] scrobbling +* Jukebox mode _Supysonic_ currently targets the version 1.9.0 of the _Subsonic_ API. For more details, go check the [API implementation status][docs-api]. @@ -221,6 +222,7 @@ _Supysonic_ comes with an optional daemon service that currently provides the following features: - background scans - library changes detection +- jukebox mode First of all, the daemon allows running backgrounds scans, meaning you can start scans from the CLI and do something else while it's scanning (otherwise the scan @@ -233,6 +235,9 @@ can listen to any library change and update the database accordingly. This watcher is started along with the daemon but can be disabled to only keep background scans. +Finally, the daemon acts as a backend for the jukebox mode, allowing to play +audio on the machine running Supysonic. + The daemon is `supysonic-daemon`, it is a non-exiting process. If you want to keep it running in background, either use the old `nohup` or `screen` methods, or start it as a _systemd_ unit (see the very basic _supysonic-daemon.service_ diff --git a/config.sample b/config.sample index c980c0f..2c61feb 100644 --- a/config.sample +++ b/config.sample @@ -46,6 +46,9 @@ run_watcher = yes ; single file over a short time span. Default: 5 wait_delay = 5 +; Command used by the jukebox +jukebox_command = mplayer -ss %offset %path + ; Optional rotating log file for the scanner daemon. Logs to stderr if empty log_file = /var/supysonic/supysonic-daemon.log log_level = INFO diff --git a/docs/api.md b/docs/api.md index d5ce91d..e81640e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,7 +85,7 @@ or with version 1.8.0. | [`deletePodcastChannel`](#deletepodcastchannel) | 1.9.0 | ❔ | | [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | ❔ | | [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | ❔ | -| [`jukeboxControl`](#jukeboxcontrol) | | 📅 | +| [`jukeboxControl`](#jukeboxcontrol) | | ✔️ | | [`getInternetRadioStations`](#getinternetradiostations) | 1.9.0 | ❔ | | [`createInternetRadioStation`](#createinternetradiostation) | 1.16.0 | ❔ | | [`updateInternetRadioStation`](#updateinternetradiostation) | 1.16.0 | ❔ | @@ -606,15 +606,15 @@ No parameter ### Jukebox #### `jukeboxControl` -📅 +✔️ | Parameter | Vers. | | |-----------|-------|---| -| `action` | | 📅 | -| `index` | | 📅 | -| `offset` | | 📅 | -| `id` | | 📅 | -| `gain` | | 📅 | +| `action` | | ✔️ | +| `index` | | ✔️ | +| `offset` | | ✔️ | +| `id` | | ✔️ | +| `gain` | | ❌ | ### Internet radio diff --git a/docs/configuration.md b/docs/configuration.md index b1b8196..3e825bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,6 +146,9 @@ changes. Default: yes have been detected. This prevents running too many scans when multiple changes are detected for a single file over a short time span. Default: 5 seconds. +`jukebox_command` : command used by the jukebox mode to play a single file. +See the [jukebox documentation](jukebox.md) for more details. + `log_file`: rotating file where events generated by the file watcher are logged. If left empty, any logging will be sent to stderr. @@ -167,6 +170,9 @@ run_watcher = yes ; single file over a short time span. Default: 5 wait_delay = 5 +; Command used by the jukebox +jukebox_command = mplayer -ss %offset %path + ; Optional rotating log file for the scanner daemon. Logs to stderr if empty log_file = /var/supysonic/supysonic-daemon.log log_level = INFO diff --git a/docs/jukebox.md b/docs/jukebox.md new file mode 100644 index 0000000..5d3a0b8 --- /dev/null +++ b/docs/jukebox.md @@ -0,0 +1,43 @@ +# Jukebox + +The jukebox mode allow playing audio files on the hardware of the machine +running Supysonic, using regular clients that support it as a remote control. + +The daemon must be running in order to be able to use the jukebox mode. So be +sure to start the `supysonic-daemon` command and keep it running. A basic +_systemd_ service file can be found at the root of the project folder. + +## Setting the player program + +Jukebox mode in _Supysonic_ works through the use of third-party command-line +programs. _Supysonic_ isn't bundled with such programs, and you are left to +choose which one you want to use. The chosen program should be able to play a +single audio file from a path specified on its command-line. + +The configuration is done in the `[daemon]` section of the +[configuration file](configuration.md), with the `jukebox_command` variable. +This variable should include the following fields: + +- `%path`: absolute path of the file to be played +- `%offset`: time in seconds where to start playing (used for seeking) + +Here's an example using `mplayer`: +``` +jukebox_command = mplayer -ss %offset %path +``` + +Or using `mpv`: +``` +jukebox_command = mpv --start=%offset %path +``` + +Setting the output volume isn't currently supported. + +## Allowing users to act on the jukebox + +The jukebox mode is only accessible to chosen users. Granting (or revoking) +jukebox usage rights to a specific user is done with the [CLI](cli.md): + +``` +$ supysonic-cli user setroles --jukebox +``` diff --git a/tests/assets/folder/silence.mp3 b/tests/assets/folder/silence.mp3 index 02c2c8b5f482a68199251bad6352e17d128746fd..807aed4cde3d3643fb122e7931b4e2529b7cd5a5 100644 GIT binary patch delta 147 zcmdlok9EsD)_PADV-^M=ur~qI$mIue9m`XT!3uMdN^^nQ{eyuLqCg2tBO_yD jb0Y&|GYeA_b8}M*19Jw)fQ>QM^3A*Cx9^f?tkMSnsDvUb delta 115 zcmdlok9EsD)+kRGV-^M=ur~ Date: Sat, 23 Nov 2019 15:34:30 +0100 Subject: [PATCH 10/11] Revert test file changes (wtf) --- tests/assets/folder/silence.mp3 | Bin 85812 -> 85812 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/assets/folder/silence.mp3 b/tests/assets/folder/silence.mp3 index 807aed4cde3d3643fb122e7931b4e2529b7cd5a5..02c2c8b5f482a68199251bad6352e17d128746fd 100644 GIT binary patch delta 115 zcmdlok9EsD)+kRGV-^M=ur~qI$mIue9m`XT!3uMdN^^nQ{eyuLqCg2tBO_yD jb0Y&|GYeA_b8}M*19Jw)fQ>QM^3A*Cx9^f?tkMSnsDvUb From 7cb825cedb0eb6c8ae89934147e7368efcddf07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sat, 23 Nov 2019 15:43:27 +0100 Subject: [PATCH 11/11] Update tests --- tests/base/test_cli.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index f0174d4..ad1e1b5 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -120,11 +120,31 @@ class CLITestCase(unittest.TestCase): def test_user_setadmin(self): self.__cli.onecmd("user add -p Alic3 alice") - self.__cli.onecmd("user setadmin alice") - self.__cli.onecmd("user setadmin bob") + self.__cli.onecmd("user setroles -A alice") + self.__cli.onecmd("user setroles -A bob") with db_session: self.assertTrue(User.get(name="alice").admin) + def test_user_unsetadmin(self): + self.__cli.onecmd("user add -p Alic3 alice") + self.__cli.onecmd("user setroles -A alice") + self.__cli.onecmd("user setroles -a alice") + with db_session: + self.assertFalse(User.get(name="alice").admin) + + def test_user_setjukebox(self): + self.__cli.onecmd("user add -p Alic3 alice") + self.__cli.onecmd("user setroles -J alice") + with db_session: + self.assertTrue(User.get(name="alice").jukebox) + + def test_user_unsetjukebox(self): + self.__cli.onecmd("user add -p Alic3 alice") + self.__cli.onecmd("user setroles -J alice") + self.__cli.onecmd("user setroles -j alice") + with db_session: + self.assertFalse(User.get(name="alice").jukebox) + def test_user_changepass(self): self.__cli.onecmd("user add -p Alic3 alice") self.__cli.onecmd("user changepass alice newpass")