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