diff --git a/README.md b/README.md index ffcbfe1..6b5e241 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 7086ff5..66c38ac 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/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/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/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/__init__.py b/supysonic/api/__init__.py index 0e7b1ba..4d40ee0 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -115,4 +115,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..02fcef4 --- /dev/null +++ b/supysonic/api/jukebox.py @@ -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) diff --git a/supysonic/cli.py b/supysonic/cli.py index 0e624d0..1ad6012 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/config.py b/supysonic/config.py index 7a45c4c..203cf29 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -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", } diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index 981c1b9..065e685 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -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() diff --git a/supysonic/daemon/server.py b/supysonic/daemon/server.py index c6d45cc..fa2d07b 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) + 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() diff --git a/supysonic/db.py b/supysonic/db.py old mode 100644 new mode 100755 index f619063..30cfd23 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -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() diff --git a/supysonic/jukebox.py b/supysonic/jukebox.py new file mode 100644 index 0000000..5458b67 --- /dev/null +++ b/supysonic/jukebox.py @@ -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 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 ebe0820..c298cf5 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 f763148..505d721 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 40e8441..ce49593 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, diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index c8b8218..3b46eeb 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -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")