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:
commit
078c98a427
@ -14,6 +14,7 @@ Current supported features are:
|
|||||||
* cover arts (as image files in the same folder as music files)
|
* cover arts (as image files in the same folder as music files)
|
||||||
* starred tracks/albums and ratings
|
* starred tracks/albums and ratings
|
||||||
* [Last.FM][lastfm] scrobbling
|
* [Last.FM][lastfm] scrobbling
|
||||||
|
* Jukebox mode
|
||||||
|
|
||||||
_Supysonic_ currently targets the version 1.9.0 of the _Subsonic_ API. For more
|
_Supysonic_ currently targets the version 1.9.0 of the _Subsonic_ API. For more
|
||||||
details, go check the [API implementation status][docs-api].
|
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:
|
following features:
|
||||||
- background scans
|
- background scans
|
||||||
- library changes detection
|
- library changes detection
|
||||||
|
- jukebox mode
|
||||||
|
|
||||||
First of all, the daemon allows running backgrounds scans, meaning you can start
|
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
|
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
|
watcher is started along with the daemon but can be disabled to only keep
|
||||||
background scans.
|
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
|
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,
|
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_
|
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
|
; single file over a short time span. Default: 5
|
||||||
wait_delay = 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
|
; Optional rotating log file for the scanner daemon. Logs to stderr if empty
|
||||||
log_file = /var/supysonic/supysonic-daemon.log
|
log_file = /var/supysonic/supysonic-daemon.log
|
||||||
log_level = INFO
|
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 | ❔ |
|
| [`deletePodcastChannel`](#deletepodcastchannel) | 1.9.0 | ❔ |
|
||||||
| [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | ❔ |
|
| [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | ❔ |
|
||||||
| [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | ❔ |
|
| [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | ❔ |
|
||||||
| [`jukeboxControl`](#jukeboxcontrol) | | 📅 |
|
| [`jukeboxControl`](#jukeboxcontrol) | | ✔️ |
|
||||||
| [`getInternetRadioStations`](#getinternetradiostations) | 1.9.0 | ❔ |
|
| [`getInternetRadioStations`](#getinternetradiostations) | 1.9.0 | ❔ |
|
||||||
| [`createInternetRadioStation`](#createinternetradiostation) | 1.16.0 | ❔ |
|
| [`createInternetRadioStation`](#createinternetradiostation) | 1.16.0 | ❔ |
|
||||||
| [`updateInternetRadioStation`](#updateinternetradiostation) | 1.16.0 | ❔ |
|
| [`updateInternetRadioStation`](#updateinternetradiostation) | 1.16.0 | ❔ |
|
||||||
@ -606,15 +606,15 @@ No parameter
|
|||||||
### Jukebox
|
### Jukebox
|
||||||
|
|
||||||
#### `jukeboxControl`
|
#### `jukeboxControl`
|
||||||
📅
|
✔️
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|-----------|-------|---|
|
|-----------|-------|---|
|
||||||
| `action` | | 📅 |
|
| `action` | | ✔️ |
|
||||||
| `index` | | 📅 |
|
| `index` | | ✔️ |
|
||||||
| `offset` | | 📅 |
|
| `offset` | | ✔️ |
|
||||||
| `id` | | 📅 |
|
| `id` | | ✔️ |
|
||||||
| `gain` | | 📅 |
|
| `gain` | | ❌ |
|
||||||
|
|
||||||
### Internet radio
|
### Internet radio
|
||||||
|
|
||||||
|
12
docs/cli.md
12
docs/cli.md
@ -27,24 +27,26 @@ Arguments:
|
|||||||
|
|
||||||
```
|
```
|
||||||
Usage:
|
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 delete <user>
|
||||||
supysonic-cli user changepass <user> <password>
|
supysonic-cli user changepass <user> <password>
|
||||||
supysonic-cli user list
|
supysonic-cli user list
|
||||||
supysonic-cli user setadmin [--off] <user>
|
supysonic-cli user setroles [-a|-A] [-j|-J] <user>
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
add Add a new user
|
add Add a new user
|
||||||
delete Delete the user
|
delete Delete the user
|
||||||
changepass Change the user's password
|
changepass Change the user's password
|
||||||
list List all the users
|
list List all the users
|
||||||
setadmin Give admin rights to the user
|
setroles Give or remove rights to the user
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-a --admin Create the user with admin rights
|
|
||||||
-p --password <password> Specify the user's password
|
-p --password <password> Specify the user's password
|
||||||
-e --email <email> Specify the user's email
|
-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
|
## Folder management commands
|
||||||
|
@ -146,6 +146,9 @@ changes. Default: yes
|
|||||||
have been detected. This prevents running too many scans when multiple changes
|
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.
|
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.
|
`log_file`: rotating file where events generated by the file watcher are logged.
|
||||||
If left empty, any logging will be sent to stderr.
|
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
|
; single file over a short time span. Default: 5
|
||||||
wait_delay = 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
|
; Optional rotating log file for the scanner daemon. Logs to stderr if empty
|
||||||
log_file = /var/supysonic/supysonic-daemon.log
|
log_file = /var/supysonic/supysonic-daemon.log
|
||||||
log_level = INFO
|
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
|
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 **delete** <user>
|
||||||
| supysonic-cli user **changepass** <user> <password>
|
| supysonic-cli user **changepass** <user> <password>
|
||||||
| supysonic-cli user **list**
|
| supysonic-cli user **list**
|
||||||
| supysonic-cli user **setadmin** [--off] <user>
|
| supysonic-cli user **setroles** [-a|-A] [-j|-J] <user>
|
||||||
|
|
||||||
Arguments
|
Arguments
|
||||||
=========
|
=========
|
||||||
@ -26,22 +26,28 @@ Arguments
|
|||||||
| **delete** Delete the user
|
| **delete** Delete the user
|
||||||
| **changepass** Change the user's password
|
| **changepass** Change the user's password
|
||||||
| **list** List all the users
|
| **list** List all the users
|
||||||
| **setadmin** Give admin rights to the user
|
| **setroles** Give or remove rights to the user
|
||||||
|
|
||||||
Options
|
Options
|
||||||
=======
|
=======
|
||||||
|
|
||||||
| **-a** | **--admin**
|
|
||||||
| Create the user with admin rights
|
|
||||||
|
|
||||||
| **-p** | **--password** *<password>*
|
| **-p** | **--password** *<password>*
|
||||||
| Specify the user's password
|
| Specify the user's password
|
||||||
|
|
||||||
| **-e** | **--email** *<email>*
|
| **-e** | **--email** *<email>*
|
||||||
| Specify the user's email
|
| Specify the user's email
|
||||||
|
|
||||||
| **--off**
|
| **-a** | **--noadmin**
|
||||||
| Revoke the admin rights if present
|
| Revoke admin rights
|
||||||
|
|
||||||
|
| **-A** | **--admin**
|
||||||
|
| Grant admin rights
|
||||||
|
|
||||||
|
| **-j** | **--nojukebox**
|
||||||
|
| Revoke jukebox rights
|
||||||
|
|
||||||
|
| **-J** | **--jukebox**
|
||||||
|
| Grant jukebox rights
|
||||||
|
|
||||||
Examples
|
Examples
|
||||||
========
|
========
|
||||||
|
@ -115,4 +115,5 @@ from .annotation import *
|
|||||||
from .chat import *
|
from .chat import *
|
||||||
from .search import *
|
from .search import *
|
||||||
from .playlists import *
|
from .playlists import *
|
||||||
|
from .jukebox import *
|
||||||
from .unsupported 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
|
"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("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(
|
user_add_parser.add_argument(
|
||||||
"-p", "--password", help="Specifies the user's password"
|
"-p", "--password", help="Specifies the user's password"
|
||||||
)
|
)
|
||||||
@ -326,16 +323,25 @@ class SupysonicCLI(cmd.Cmd):
|
|||||||
"delete", help="Deletes a user", add_help=False
|
"delete", help="Deletes a user", add_help=False
|
||||||
)
|
)
|
||||||
user_del_parser.add_argument("name", help="Name/login of the user to delete")
|
user_del_parser.add_argument("name", help="Name/login of the user to delete")
|
||||||
user_admin_parser = user_subparsers.add_parser(
|
user_roles_parser = user_subparsers.add_parser(
|
||||||
"setadmin", help="Enable/disable admin rights for a user", add_help=False
|
"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"
|
"name", help="Name/login of the user to grant/revoke admin rights"
|
||||||
)
|
)
|
||||||
user_admin_parser.add_argument(
|
user_roles_admin_group = user_roles_parser.add_mutually_exclusive_group()
|
||||||
"--off",
|
user_roles_admin_group.add_argument(
|
||||||
action="store_true",
|
"-A", "--admin", action="store_true", help="Grant admin rights"
|
||||||
help="Revoke admin rights if present, grant them otherwise",
|
)
|
||||||
|
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(
|
user_pass_parser = user_subparsers.add_parser(
|
||||||
"changepass", help="Changes a user's password", add_help=False
|
"changepass", help="Changes a user's password", add_help=False
|
||||||
@ -347,10 +353,13 @@ class SupysonicCLI(cmd.Cmd):
|
|||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def user_list(self):
|
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(
|
self.write_line(
|
||||||
"\n".join(
|
"\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()
|
for u in User.select()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -363,11 +372,11 @@ class SupysonicCLI(cmd.Cmd):
|
|||||||
return password
|
return password
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def user_add(self, name, admin, password, email):
|
def user_add(self, name, password, email):
|
||||||
try:
|
try:
|
||||||
if not password:
|
if not password:
|
||||||
password = self._ask_password() # pragma: nocover
|
password = self._ask_password() # pragma: nocover
|
||||||
UserManager.add(name, password, email, admin)
|
UserManager.add(name, password, email, False)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
self.write_error_line(str(e))
|
self.write_error_line(str(e))
|
||||||
|
|
||||||
@ -380,15 +389,23 @@ class SupysonicCLI(cmd.Cmd):
|
|||||||
self.write_error_line(str(e))
|
self.write_error_line(str(e))
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def user_setadmin(self, name, off):
|
def user_setroles(self, name, admin, noadmin, jukebox, nojukebox):
|
||||||
user = User.get(name=name)
|
user = User.get(name=name)
|
||||||
if user is None:
|
if user is None:
|
||||||
self.write_error_line("No such user")
|
self.write_error_line("No such user")
|
||||||
else:
|
else:
|
||||||
user.admin = not off
|
if admin:
|
||||||
self.write_line(
|
user.admin = True
|
||||||
"{0} '{1}' admin rights".format("Revoked" if off else "Granted", name)
|
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
|
@db_session
|
||||||
def user_changepass(self, name, password):
|
def user_changepass(self, name, password):
|
||||||
|
@ -42,6 +42,7 @@ class DefaultConfig(object):
|
|||||||
"socket": os.path.join(tempdir, "supysonic.sock"),
|
"socket": os.path.join(tempdir, "supysonic.sock"),
|
||||||
"run_watcher": True,
|
"run_watcher": True,
|
||||||
"wait_delay": 5,
|
"wait_delay": 5,
|
||||||
|
"jukebox_command": None,
|
||||||
"log_file": None,
|
"log_file": None,
|
||||||
"log_level": "WARNING",
|
"log_level": "WARNING",
|
||||||
}
|
}
|
||||||
|
@ -58,6 +58,50 @@ class ScannerStartCommand(ScannerCommand):
|
|||||||
daemon.start_scan(self.__folders, self.__force)
|
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):
|
class DaemonCommandResult(object):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -69,6 +113,21 @@ class ScannerProgressResult(DaemonCommandResult):
|
|||||||
scanned = property(lambda self: self.__scanned)
|
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):
|
class DaemonClient(object):
|
||||||
def __init__(self, address=None):
|
def __init__(self, address=None):
|
||||||
self.__address = address or get_current_config().DAEMON["socket"]
|
self.__address = address or get_current_config().DAEMON["socket"]
|
||||||
@ -106,3 +165,10 @@ class DaemonClient(object):
|
|||||||
raise TypeError("Expecting list, got " + str(type(folders)))
|
raise TypeError("Expecting list, got " + str(type(folders)))
|
||||||
with self.__get_connection() as c:
|
with self.__get_connection() as c:
|
||||||
c.send(ScannerStartCommand(folders, force))
|
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 .client import DaemonCommand
|
||||||
from ..db import Folder
|
from ..db import Folder
|
||||||
|
from ..jukebox import Jukebox
|
||||||
from ..scanner import Scanner
|
from ..scanner import Scanner
|
||||||
from ..utils import get_secret_key
|
from ..utils import get_secret_key
|
||||||
from ..watcher import SupysonicWatcher
|
from ..watcher import SupysonicWatcher
|
||||||
@ -31,10 +32,12 @@ class Daemon(object):
|
|||||||
self.__listener = None
|
self.__listener = None
|
||||||
self.__watcher = None
|
self.__watcher = None
|
||||||
self.__scanner = None
|
self.__scanner = None
|
||||||
|
self.__jukebox = None
|
||||||
self.__stopped = Event()
|
self.__stopped = Event()
|
||||||
|
|
||||||
watcher = property(lambda self: self.__watcher)
|
watcher = property(lambda self: self.__watcher)
|
||||||
scanner = property(lambda self: self.__scanner)
|
scanner = property(lambda self: self.__scanner)
|
||||||
|
jukebox = property(lambda self: self.__jukebox)
|
||||||
|
|
||||||
def __handle_connection(self, connection):
|
def __handle_connection(self, connection):
|
||||||
cmd = connection.recv()
|
cmd = connection.recv()
|
||||||
@ -56,6 +59,9 @@ class Daemon(object):
|
|||||||
self.__watcher = SupysonicWatcher(self.__config)
|
self.__watcher = SupysonicWatcher(self.__config)
|
||||||
self.__watcher.start()
|
self.__watcher.start()
|
||||||
|
|
||||||
|
if self.__config.DAEMON["jukebox_command"]:
|
||||||
|
self.__jukebox = Jukebox(self.__config.DAEMON["jukebox_command"])
|
||||||
|
|
||||||
Thread(target=self.__listen).start()
|
Thread(target=self.__listen).start()
|
||||||
while not self.__stopped.is_set():
|
while not self.__stopped.is_set():
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@ -109,3 +115,5 @@ class Daemon(object):
|
|||||||
self.__scanner.join()
|
self.__scanner.join()
|
||||||
if self.__watcher is not None:
|
if self.__watcher is not None:
|
||||||
self.__watcher.stop()
|
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 urllib.parse import urlparse, parse_qsl
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
SCHEMA_VERSION = "20190915"
|
SCHEMA_VERSION = "20190921"
|
||||||
|
|
||||||
|
|
||||||
def now():
|
def now():
|
||||||
@ -354,7 +354,10 @@ class User(db.Entity):
|
|||||||
mail = Optional(str)
|
mail = Optional(str)
|
||||||
password = Required(str, 40)
|
password = Required(str, 40)
|
||||||
salt = Required(str, 6)
|
salt = Required(str, 6)
|
||||||
|
|
||||||
admin = Required(bool, default=False)
|
admin = Required(bool, default=False)
|
||||||
|
jukebox = Required(bool, default=False)
|
||||||
|
|
||||||
lastfm_session = Optional(str, 32, nullable=True)
|
lastfm_session = Optional(str, 32, nullable=True)
|
||||||
lastfm_status = Required(
|
lastfm_status = Required(
|
||||||
bool, default=True
|
bool, default=True
|
||||||
@ -388,7 +391,7 @@ class User(db.Entity):
|
|||||||
commentRole=False,
|
commentRole=False,
|
||||||
podcastRole=False,
|
podcastRole=False,
|
||||||
streamRole=True,
|
streamRole=True,
|
||||||
jukeboxRole=False,
|
jukeboxRole=self.admin or self.jukebox,
|
||||||
shareRole=False,
|
shareRole=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -645,7 +648,6 @@ def init_database(database_uri):
|
|||||||
|
|
||||||
db.generate_mapping(check_tables=False)
|
db.generate_mapping(check_tables=False)
|
||||||
|
|
||||||
|
|
||||||
def release_database():
|
def release_database():
|
||||||
metadb.disconnect()
|
metadb.disconnect()
|
||||||
db.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,
|
password CHAR(40) NOT NULL,
|
||||||
salt CHAR(6) NOT NULL,
|
salt CHAR(6) NOT NULL,
|
||||||
admin BOOLEAN NOT NULL,
|
admin BOOLEAN NOT NULL,
|
||||||
|
jukebox BOOLEAN NOT NULL,
|
||||||
lastfm_session CHAR(32),
|
lastfm_session CHAR(32),
|
||||||
lastfm_status BOOLEAN NOT NULL,
|
lastfm_status BOOLEAN NOT NULL,
|
||||||
last_play_id BINARY(16) REFERENCES track,
|
last_play_id BINARY(16) REFERENCES track,
|
||||||
|
@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS "user" (
|
|||||||
password CHAR(40) NOT NULL,
|
password CHAR(40) NOT NULL,
|
||||||
salt CHAR(6) NOT NULL,
|
salt CHAR(6) NOT NULL,
|
||||||
admin BOOLEAN NOT NULL,
|
admin BOOLEAN NOT NULL,
|
||||||
|
jukebox BOOLEAN NOT NULL,
|
||||||
lastfm_session CHAR(32),
|
lastfm_session CHAR(32),
|
||||||
lastfm_status BOOLEAN NOT NULL,
|
lastfm_status BOOLEAN NOT NULL,
|
||||||
last_play_id UUID REFERENCES track,
|
last_play_id UUID REFERENCES track,
|
||||||
|
@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS user (
|
|||||||
password CHAR(40) NOT NULL,
|
password CHAR(40) NOT NULL,
|
||||||
salt CHAR(6) NOT NULL,
|
salt CHAR(6) NOT NULL,
|
||||||
admin BOOLEAN NOT NULL,
|
admin BOOLEAN NOT NULL,
|
||||||
|
jukebox BOOLEAN NOT NULL,
|
||||||
lastfm_session CHAR(32),
|
lastfm_session CHAR(32),
|
||||||
lastfm_status BOOLEAN NOT NULL,
|
lastfm_status BOOLEAN NOT NULL,
|
||||||
last_play_id CHAR(36) REFERENCES track,
|
last_play_id CHAR(36) REFERENCES track,
|
||||||
|
@ -116,11 +116,31 @@ class CLITestCase(unittest.TestCase):
|
|||||||
|
|
||||||
def test_user_setadmin(self):
|
def test_user_setadmin(self):
|
||||||
self.__cli.onecmd("user add -p Alic3 alice")
|
self.__cli.onecmd("user add -p Alic3 alice")
|
||||||
self.__cli.onecmd("user setadmin alice")
|
self.__cli.onecmd("user setroles -A alice")
|
||||||
self.__cli.onecmd("user setadmin bob")
|
self.__cli.onecmd("user setroles -A bob")
|
||||||
with db_session:
|
with db_session:
|
||||||
self.assertTrue(User.get(name="alice").admin)
|
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):
|
def test_user_changepass(self):
|
||||||
self.__cli.onecmd("user add -p Alic3 alice")
|
self.__cli.onecmd("user add -p Alic3 alice")
|
||||||
self.__cli.onecmd("user changepass alice newpass")
|
self.__cli.onecmd("user changepass alice newpass")
|
||||||
|
Loading…
Reference in New Issue
Block a user