mirror of
https://github.com/spl0k/supysonic.git
synced 2024-11-09 11:42:16 +00:00
Merge branch 'jukebox'
This commit is contained in:
commit
078c98a427
@ -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_
|
||||
|
@ -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
|
||||
|
14
docs/api.md
14
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
|
||||
|
||||
|
12
docs/cli.md
12
docs/cli.md
@ -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
|
||||
|
@ -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
43
docs/jukebox.md
Normal 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>
|
||||
```
|
@ -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
|
||||
========
|
||||
|
@ -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
102
supysonic/api/jukebox.py
Normal 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)
|
@ -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):
|
||||
|
@ -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",
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
8
supysonic/db.py
Normal file → Executable 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
183
supysonic/jukebox.py
Normal 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
|
1
supysonic/schema/migration/mysql/20190921.sql
Normal file
1
supysonic/schema/migration/mysql/20190921.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE user ADD jukebox BOOLEAN DEFAULT false NOT NULL AFTER admin;
|
1
supysonic/schema/migration/postgres/20190921.sql
Normal file
1
supysonic/schema/migration/postgres/20190921.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD jukebox BOOLEAN DEFAULT false NOT NULL;
|
1
supysonic/schema/migration/sqlite/20190921.sql
Normal file
1
supysonic/schema/migration/sqlite/20190921.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE user ADD jukebox BOOLEAN DEFAULT false NOT NULL;
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user