1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 08:56:17 +00:00

Merge branch 'jukebox'

This commit is contained in:
Alban Féron 2019-12-24 15:47:19 +01:00
commit 078c98a427
No known key found for this signature in database
GPG Key ID: 8CE0313646D16165
22 changed files with 515 additions and 44 deletions

View File

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

View File

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

View File

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

View File

@ -27,24 +27,26 @@ Arguments:
```
Usage:
supysonic-cli user add <user> [-a] [-p <password>] [-e <email>]
supysonic-cli user add <user> [-p <password>] [-e <email>]
supysonic-cli user delete <user>
supysonic-cli user changepass <user> <password>
supysonic-cli user list
supysonic-cli user setadmin [--off] <user>
supysonic-cli user setroles [-a|-A] [-j|-J] <user>
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 <password> Specify the user's password
-e --email <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

View File

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

43
docs/jukebox.md Normal file
View File

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

View File

@ -13,11 +13,11 @@ Supysonic user management commands
Synopsis
========
| supysonic-cli user **add** <user> [-a] [-p <password>] [-e <email>]
| supysonic-cli user **add** <user> [-p <password>] [-e <email>]
| supysonic-cli user **delete** <user>
| supysonic-cli user **changepass** <user> <password>
| supysonic-cli user **list**
| supysonic-cli user **setadmin** [--off] <user>
| supysonic-cli user **setroles** [-a|-A] [-j|-J] <user>
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** *<password>*
| Specify the user's password
| **-e** | **--email** *<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
========

View File

@ -115,4 +115,5 @@ from .annotation import *
from .chat import *
from .search import *
from .playlists import *
from .jukebox import *
from .unsupported import *

102
supysonic/api/jukebox.py Normal file
View File

@ -0,0 +1,102 @@
# 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, 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")
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")
args = ()
if action == "set":
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:
args = (int(index), 0)
elif action == "add":
if not id:
raise MissingParameter("id")
else:
args = [uuid.UUID(i) for i in id]
elif action == "remove":
if not index:
raise MissingParameter("index")
else:
args = (int(index),)
elif action == "setGain":
if not gain:
raise MissingParameter("gain")
else:
args = (float(gain),)
try:
status = DaemonClient(current_app.config["DAEMON"]["socket"]).jukebox_control(
action, *args
)
except DaemonUnavailableError:
raise GenericError("Jukebox unavaliable")
rv = dict(
currentIndex=status.index,
playing=status.playing,
gain=status.gain,
position=status.position,
)
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)

View File

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

View File

@ -42,6 +42,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",
}

View File

@ -58,6 +58,50 @@ class ScannerStartCommand(ScannerCommand):
daemon.start_scan(self.__folders, self.__force)
class JukeboxCommand(DaemonCommand):
def __init__(self, action, args):
self.__action = action
self.__args = args
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
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
connection.send(rv)
class DaemonCommandResult(object):
pass
@ -69,6 +113,21 @@ 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
self.position = 0
else:
self.playing = jukebox.playing
self.index = jukebox.index
self.gain = jukebox.gain
self.position = jukebox.position
self.playlist = ()
class DaemonClient(object):
def __init__(self, address=None):
self.__address = address or get_current_config().DAEMON["socket"]
@ -106,3 +165,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()

View File

@ -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)
jukebox = 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()

8
supysonic/db.py Normal file → Executable file
View File

@ -23,7 +23,7 @@ from pony.orm import db_session
from urllib.parse import urlparse, parse_qsl
from uuid import UUID, uuid4
SCHEMA_VERSION = "20190915"
SCHEMA_VERSION = "20190921"
def now():
@ -354,7 +354,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
@ -388,7 +391,7 @@ class User(db.Entity):
commentRole=False,
podcastRole=False,
streamRole=True,
jukeboxRole=False,
jukeboxRole=self.admin or self.jukebox,
shareRole=False,
)
@ -645,7 +648,6 @@ def init_database(database_uri):
db.generate_mapping(check_tables=False)
def release_database():
metadb.disconnect()
db.disconnect()

183
supysonic/jukebox.py Normal file
View File

@ -0,0 +1,183 @@
# 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 os
import shlex
import time
from datetime import datetime, timedelta
from pony.orm import db_session, ObjectNotFound
from random import shuffle
from subprocess import Popen
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 = 0
self.__offset = 0
self.__start = None
self.__devnull = None
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))
@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:
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)
def start(self):
if self.playing or not self.__playlist:
return
self.__skip.clear()
self.__stop.clear()
self.__offset = 0
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, offset):
if index < 0 or index >= len(self.__playlist):
raise IndexError()
if offset < 0:
raise ValueError()
with self.__lock:
self.__index = index
self.__offset = offset
self.__start = datetime.utcnow() - timedelta(seconds=offset)
self.__skip.set()
self.start()
def add(self, *tracks):
with self.__lock:
with db_session:
for t in tracks:
try:
self.__playlist.append(Track[t].path)
except ObjectNotFound:
pass
def clear(self):
with self.__lock:
self.__playlist.clear()
self.__index = 0
self.__offset = 0
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):
pass
def terminate(self):
self.__stop.set()
if self.__thread is not None:
self.__thread.join()
def __play_thread(self):
proc = None
while not self.__stop.is_set():
if self.__skip.is_set():
proc.terminate()
proc.wait()
proc = None
self.__skip.clear()
if proc is None:
with self.__lock:
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
proc = self.__play_file()
time.sleep(0.1)
proc.terminate()
proc.wait()
self._close_devnull()
self.__start = None
def __play_file(self):
path = self.__playlist[self.__index]
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:
return Popen(
args,
stdin=self._get_devnull(),
stdout=self._get_devnull(),
stderr=self._get_devnull(),
)
except:
logger.exception("Failed running play command")
return None

View File

@ -0,0 +1 @@
ALTER TABLE user ADD jukebox BOOLEAN DEFAULT false NOT NULL AFTER admin;

View File

@ -0,0 +1 @@
ALTER TABLE "user" ADD jukebox BOOLEAN DEFAULT false NOT NULL;

View File

@ -0,0 +1 @@
ALTER TABLE user ADD jukebox BOOLEAN DEFAULT false NOT NULL;

View File

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

View File

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

View File

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

View File

@ -116,11 +116,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")