tinyjukebox 0.47.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Upload of new albums: mp3 files are collected in a staging folder; on 'finish' the playlist is written, the
|
|
2
|
+
folder is moved into the albums folder and the album is added to the targets."""
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from tinyjukebox.core.logging import get_child
|
|
15
|
+
from tinyjukebox.data import targetalbumtools
|
|
16
|
+
from tinyjukebox.data.target import TargetAlbum, check_album_title, is_mp3_filename
|
|
17
|
+
from tinyjukebox.data.targetalbumtools import natural_key, playlist_order
|
|
18
|
+
from tinyjukebox.data.targets import Targets
|
|
19
|
+
from tinyjukebox.errors import BadRequest, Conflict, NotFound, PayloadTooLarge
|
|
20
|
+
from tinyjukebox.metadata import TrackInfo, mp3_info, read_track
|
|
21
|
+
|
|
22
|
+
__all__ = ["MAX_FILES", "MIN_FREE_SPACE", "STAGING_FOLDER", "UPLOAD_EXPIRY", "Upload", "Uploads", "safe_name"]
|
|
23
|
+
|
|
24
|
+
STAGING_FOLDER = ".tinyjukebox-uploads"
|
|
25
|
+
UPLOAD_EXPIRY = 3600 # uploads without changes for this many seconds are discarded
|
|
26
|
+
MAX_FILES = 500 # files per upload
|
|
27
|
+
MIN_FREE_SPACE = 200 * 2**20 # bytes that must stay free on the albums file system after an upload
|
|
28
|
+
# no path separators, no '#' (comment in m3u files), no shell or url specials
|
|
29
|
+
_UNSAFE = re.compile(r"[^\w \-–—.,()&'!+]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def safe_name(name: str | None, what: str = "name") -> str:
|
|
33
|
+
"""File or folder name without path separators or other surprising characters."""
|
|
34
|
+
name = _UNSAFE.sub("_", (name or "").strip()).strip(" .")
|
|
35
|
+
if not name:
|
|
36
|
+
raise BadRequest(f"invalid {what}.", code="upload.name_invalid")
|
|
37
|
+
if len(name.encode("utf-8")) > 200:
|
|
38
|
+
raise BadRequest(f"{what} is too long.", code="upload.name_too_long")
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Readable(Protocol):
|
|
43
|
+
def read(self, size: int = -1, /) -> bytes: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Upload:
|
|
47
|
+
def __init__(self, upload_id: str, folder_name: str, staging_folder: str, title: str | None = None) -> None:
|
|
48
|
+
self.upload_id = upload_id
|
|
49
|
+
self.folder_name = folder_name
|
|
50
|
+
self.title = title or folder_name
|
|
51
|
+
self.staging_folder = staging_folder
|
|
52
|
+
self.files: dict[str, TrackInfo] = {}
|
|
53
|
+
self.last_change = time.monotonic()
|
|
54
|
+
self.lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
files = list(self.files.values())
|
|
58
|
+
return {
|
|
59
|
+
"id": self.upload_id,
|
|
60
|
+
"folder": self.folder_name,
|
|
61
|
+
"title": self.title,
|
|
62
|
+
"files": sorted(files, key=lambda f: natural_key(f["filename"])),
|
|
63
|
+
"playlist": playlist_order(files),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Uploads:
|
|
68
|
+
"""Uploads in progress (in memory - leftovers of a previous run are removed on start)."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
targets: Targets,
|
|
73
|
+
playlist_filename: str,
|
|
74
|
+
max_file_size: int,
|
|
75
|
+
logger: logging.Logger,
|
|
76
|
+
min_free_space: int = MIN_FREE_SPACE,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
79
|
+
self._targets = targets
|
|
80
|
+
self._albums_folder = os.path.abspath(targets.root_folder)
|
|
81
|
+
self._playlist_filename = playlist_filename
|
|
82
|
+
self._max_file_size = max_file_size
|
|
83
|
+
self._min_free_space = min_free_space
|
|
84
|
+
self._staging_root = os.path.join(self._albums_folder, STAGING_FOLDER)
|
|
85
|
+
self._uploads: dict[str, Upload] = {}
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
|
|
88
|
+
def remove_stale_uploads(self) -> None:
|
|
89
|
+
if os.path.isdir(self._staging_root):
|
|
90
|
+
self._logger.info("removing unfinished uploads in '%s'.", self._staging_root)
|
|
91
|
+
shutil.rmtree(self._staging_root, ignore_errors=True)
|
|
92
|
+
|
|
93
|
+
def _remove_expired(self) -> None:
|
|
94
|
+
now = time.monotonic()
|
|
95
|
+
with self._lock:
|
|
96
|
+
expired = [u for u in self._uploads.values() if now - u.last_change > UPLOAD_EXPIRY]
|
|
97
|
+
for upload in expired:
|
|
98
|
+
self._logger.info("upload %s expired.", upload.upload_id)
|
|
99
|
+
with contextlib.suppress(NotFound): # aborted meanwhile
|
|
100
|
+
self.abort(upload.upload_id)
|
|
101
|
+
|
|
102
|
+
def _album_folder(self, folder_name: str) -> str:
|
|
103
|
+
return os.path.join(self._albums_folder, folder_name)
|
|
104
|
+
|
|
105
|
+
def create(self, name: object) -> dict[str, Any]:
|
|
106
|
+
"""A new upload. ``name`` is the title of the album; its folder gets the same name, without the characters
|
|
107
|
+
that a folder must not have."""
|
|
108
|
+
try:
|
|
109
|
+
title = check_album_title(name)
|
|
110
|
+
except ValueError as e:
|
|
111
|
+
raise BadRequest(f"album name: {e}", code="upload.name_invalid") from None
|
|
112
|
+
folder_name = safe_name(title, "album name")
|
|
113
|
+
self._remove_expired()
|
|
114
|
+
with self._lock:
|
|
115
|
+
if os.path.exists(self._album_folder(folder_name)):
|
|
116
|
+
raise Conflict(
|
|
117
|
+
f"album folder '{folder_name}' exists already.", code="upload.folder_exists", folder=folder_name
|
|
118
|
+
)
|
|
119
|
+
if any(u.folder_name == folder_name for u in self._uploads.values()):
|
|
120
|
+
raise Conflict(
|
|
121
|
+
f"an upload for '{folder_name}' is already in progress.",
|
|
122
|
+
code="upload.in_progress",
|
|
123
|
+
folder=folder_name,
|
|
124
|
+
)
|
|
125
|
+
upload_id = secrets.token_hex(8)
|
|
126
|
+
staging_folder = os.path.join(self._staging_root, upload_id)
|
|
127
|
+
os.makedirs(staging_folder)
|
|
128
|
+
upload = Upload(upload_id, folder_name, staging_folder, title)
|
|
129
|
+
self._uploads[upload_id] = upload
|
|
130
|
+
self._logger.info("upload %s for album '%s' created.", upload_id, folder_name)
|
|
131
|
+
return upload.to_dict()
|
|
132
|
+
|
|
133
|
+
def _get(self, upload_id: str) -> Upload:
|
|
134
|
+
with self._lock:
|
|
135
|
+
upload = self._uploads.get(upload_id)
|
|
136
|
+
if upload is None:
|
|
137
|
+
raise NotFound(f"unknown upload '{upload_id}'.", code="upload.unknown")
|
|
138
|
+
return upload
|
|
139
|
+
|
|
140
|
+
def get(self, upload_id: str) -> dict[str, Any]:
|
|
141
|
+
upload = self._get(upload_id)
|
|
142
|
+
with upload.lock:
|
|
143
|
+
return upload.to_dict()
|
|
144
|
+
|
|
145
|
+
def list(self) -> list[dict[str, Any]]:
|
|
146
|
+
self._remove_expired()
|
|
147
|
+
with self._lock:
|
|
148
|
+
uploads = list(self._uploads.values())
|
|
149
|
+
return [u.to_dict() for u in uploads]
|
|
150
|
+
|
|
151
|
+
def _check_space(self, length: int) -> None:
|
|
152
|
+
free = shutil.disk_usage(self._albums_folder).free
|
|
153
|
+
if free - length < self._min_free_space:
|
|
154
|
+
raise PayloadTooLarge(
|
|
155
|
+
f"not enough free space on the box ({free // 2**20} MB free).",
|
|
156
|
+
code="upload.no_space",
|
|
157
|
+
free=free // 2**20,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def add_file(self, upload_id: str, filename: str, stream: Readable, length: int) -> TrackInfo:
|
|
161
|
+
"""Copy ``length`` bytes from ``stream`` into the upload. Only valid mp3 files are accepted."""
|
|
162
|
+
upload = self._get(upload_id)
|
|
163
|
+
filename = safe_name(filename, "file name")
|
|
164
|
+
if not is_mp3_filename(filename):
|
|
165
|
+
raise BadRequest(f"only mp3 files are supported ('{filename}').", code="upload.only_mp3", filename=filename)
|
|
166
|
+
if filename.lower() == self._playlist_filename.lower():
|
|
167
|
+
raise BadRequest(f"'{filename}' is reserved for the playlist.", code="upload.reserved", filename=filename)
|
|
168
|
+
if length > self._max_file_size:
|
|
169
|
+
raise PayloadTooLarge(
|
|
170
|
+
f"'{filename}' is larger than {self._max_file_size // 2**20} MB.",
|
|
171
|
+
code="upload.file_too_large",
|
|
172
|
+
filename=filename,
|
|
173
|
+
limit=self._max_file_size // 2**20,
|
|
174
|
+
)
|
|
175
|
+
if len(upload.files) >= MAX_FILES and filename not in upload.files:
|
|
176
|
+
raise BadRequest(f"an album may have at most {MAX_FILES} files.", code="upload.too_many", limit=MAX_FILES)
|
|
177
|
+
self._check_space(length)
|
|
178
|
+
|
|
179
|
+
target = os.path.join(upload.staging_folder, filename)
|
|
180
|
+
partial = os.path.join(upload.staging_folder, "." + secrets.token_hex(4) + ".part")
|
|
181
|
+
try:
|
|
182
|
+
with open(partial, "wb") as f:
|
|
183
|
+
remaining = length
|
|
184
|
+
while remaining > 0:
|
|
185
|
+
chunk = stream.read(min(remaining, 64 * 1024))
|
|
186
|
+
if not chunk:
|
|
187
|
+
raise BadRequest(
|
|
188
|
+
f"upload of '{filename}' incomplete.", code="upload.incomplete", filename=filename
|
|
189
|
+
)
|
|
190
|
+
f.write(chunk)
|
|
191
|
+
remaining -= len(chunk)
|
|
192
|
+
try:
|
|
193
|
+
mp3_info(partial)
|
|
194
|
+
except Exception: # HeaderNotFoundError and everything mutagen raises for garbage
|
|
195
|
+
raise BadRequest(
|
|
196
|
+
f"'{filename}' is not a valid mp3 file.", code="upload.not_mp3", filename=filename
|
|
197
|
+
) from None
|
|
198
|
+
info = read_track(partial)
|
|
199
|
+
info["filename"] = filename
|
|
200
|
+
info["size"] = length
|
|
201
|
+
with upload.lock:
|
|
202
|
+
if self._uploads.get(upload_id) is not upload: # aborted meanwhile
|
|
203
|
+
raise NotFound(f"unknown upload '{upload_id}'.", code="upload.unknown")
|
|
204
|
+
os.replace(partial, target)
|
|
205
|
+
upload.files[filename] = info
|
|
206
|
+
upload.last_change = time.monotonic()
|
|
207
|
+
finally:
|
|
208
|
+
if os.path.exists(partial):
|
|
209
|
+
os.remove(partial)
|
|
210
|
+
self._logger.info("upload %s: received '%s' (%s bytes).", upload_id, filename, length)
|
|
211
|
+
return info
|
|
212
|
+
|
|
213
|
+
def remove_file(self, upload_id: str, filename: str) -> None:
|
|
214
|
+
upload = self._get(upload_id)
|
|
215
|
+
with upload.lock:
|
|
216
|
+
if filename not in upload.files: # only names from the list are used as path
|
|
217
|
+
raise NotFound(f"unknown file '{filename}'.")
|
|
218
|
+
os.remove(os.path.join(upload.staging_folder, filename))
|
|
219
|
+
del upload.files[filename]
|
|
220
|
+
|
|
221
|
+
def abort(self, upload_id: str) -> None:
|
|
222
|
+
with self._lock:
|
|
223
|
+
upload = self._uploads.pop(upload_id, None)
|
|
224
|
+
if upload is None:
|
|
225
|
+
raise NotFound(f"unknown upload '{upload_id}'.", code="upload.unknown")
|
|
226
|
+
with upload.lock:
|
|
227
|
+
shutil.rmtree(upload.staging_folder, ignore_errors=True)
|
|
228
|
+
self._logger.info("upload %s aborted.", upload_id)
|
|
229
|
+
|
|
230
|
+
def finish(self, upload_id: str, order: object = None) -> TargetAlbum:
|
|
231
|
+
"""Write the playlist (given order or :func:`playlist_order`), move the folder to the albums folder and add
|
|
232
|
+
the album to the targets."""
|
|
233
|
+
upload = self._get(upload_id)
|
|
234
|
+
with upload.lock:
|
|
235
|
+
if not upload.files:
|
|
236
|
+
raise BadRequest("no files uploaded.", code="upload.empty")
|
|
237
|
+
if order is None:
|
|
238
|
+
order = playlist_order(list(upload.files.values()))
|
|
239
|
+
elif not isinstance(order, list) or sorted(map(str, order)) != sorted(upload.files):
|
|
240
|
+
raise BadRequest("order must contain every uploaded file exactly once.")
|
|
241
|
+
album_folder = self._album_folder(upload.folder_name)
|
|
242
|
+
if os.path.exists(album_folder):
|
|
243
|
+
raise Conflict(
|
|
244
|
+
f"album folder '{upload.folder_name}' exists already.",
|
|
245
|
+
code="upload.folder_exists",
|
|
246
|
+
folder=upload.folder_name,
|
|
247
|
+
)
|
|
248
|
+
os.rename(upload.staging_folder, album_folder)
|
|
249
|
+
# the playlist is written after moving - a scan of the albums folder never sees a half finished album
|
|
250
|
+
targetalbumtools.write_playlist(album_folder, self._playlist_filename, [str(name) for name in order])
|
|
251
|
+
try:
|
|
252
|
+
album = targetalbumtools.add_album_to_targets(
|
|
253
|
+
self._targets, self._logger, upload.folder_name, self._playlist_filename, upload.title
|
|
254
|
+
)
|
|
255
|
+
except ValueError as e:
|
|
256
|
+
os.remove(os.path.join(album_folder, self._playlist_filename))
|
|
257
|
+
os.rename(album_folder, upload.staging_folder)
|
|
258
|
+
raise BadRequest(f"album cannot be added: {e}", code="upload.add_failed", reason=str(e)) from None
|
|
259
|
+
with self._lock:
|
|
260
|
+
del self._uploads[upload_id]
|
|
261
|
+
self._logger.info("upload %s finished - album [%s] '%s'.", upload_id, album.target_id, upload.folder_name)
|
|
262
|
+
return album
|
tinyjukebox/web/users.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""tinyjukebox-users - manage the users of the rest api.
|
|
2
|
+
|
|
3
|
+
tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml add mama --role admin
|
|
4
|
+
tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml add kind # role 'user': player, volume, buttons
|
|
5
|
+
tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml passwd mama
|
|
6
|
+
tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml role kind admin
|
|
7
|
+
tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml remove kind
|
|
8
|
+
tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml list
|
|
9
|
+
|
|
10
|
+
The running service picks up changes immediately: a new password ends the sessions of that user, a new role applies
|
|
11
|
+
at the next request. The last admin cannot be removed or made a user. Admins can do the same on the web page (tab
|
|
12
|
+
"System"); every user can change the own password there.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import getpass
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from tinyjukebox.web.auth import (
|
|
22
|
+
MAX_USERNAME_LENGTH,
|
|
23
|
+
MIN_PASSWORD_LENGTH,
|
|
24
|
+
ROLES,
|
|
25
|
+
UsersFile,
|
|
26
|
+
hash_password,
|
|
27
|
+
valid_username,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = ["MIN_PASSWORD_LENGTH", "main"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_password(args: argparse.Namespace) -> str:
|
|
34
|
+
if args.password_stdin:
|
|
35
|
+
password = sys.stdin.readline().rstrip("\n")
|
|
36
|
+
else:
|
|
37
|
+
password = getpass.getpass("password: ")
|
|
38
|
+
if getpass.getpass("repeat password: ") != password:
|
|
39
|
+
raise SystemExit("passwords do not match.")
|
|
40
|
+
if len(password) < MIN_PASSWORD_LENGTH:
|
|
41
|
+
raise SystemExit(f"password must have at least {MIN_PASSWORD_LENGTH} characters.")
|
|
42
|
+
return password
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parser() -> argparse.ArgumentParser:
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
prog="tinyjukebox-users", description="manage the users of the Tiny Jukebox rest api."
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument("-f", "--file", required=True, help="users file (rest-api/users-file in the config)")
|
|
50
|
+
parser.add_argument("--password-stdin", action="store_true", help="read the password from stdin")
|
|
51
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
add = commands.add_parser("add", help="add a user")
|
|
53
|
+
add.add_argument("name")
|
|
54
|
+
add.add_argument("--role", choices=ROLES, default="user")
|
|
55
|
+
commands.add_parser("passwd", help="change the password of a user").add_argument("name")
|
|
56
|
+
role = commands.add_parser("role", help="change the role of a user")
|
|
57
|
+
role.add_argument("name")
|
|
58
|
+
role.add_argument("role", choices=ROLES)
|
|
59
|
+
commands.add_parser("remove", help="remove a user").add_argument("name")
|
|
60
|
+
commands.add_parser("list", help="list all users")
|
|
61
|
+
return parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _keep_an_admin(users: dict[str, dict[str, Any]], name: str) -> None:
|
|
65
|
+
"""The last admin stays one: without an admin nobody can manage the page."""
|
|
66
|
+
if users[name]["role"] == "admin" and sum(user["role"] == "admin" for user in users.values()) == 1:
|
|
67
|
+
raise SystemExit(f"'{name}' is the last admin - add another admin first.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
71
|
+
args = _parser().parse_args(argv)
|
|
72
|
+
try:
|
|
73
|
+
users = UsersFile.read(args.file)
|
|
74
|
+
except ValueError as e:
|
|
75
|
+
raise SystemExit(str(e)) from None
|
|
76
|
+
|
|
77
|
+
if args.command == "list":
|
|
78
|
+
for name, user in sorted(users.items()):
|
|
79
|
+
print(f"{name} ({user['role']})")
|
|
80
|
+
return
|
|
81
|
+
if args.command == "add":
|
|
82
|
+
if args.name in users:
|
|
83
|
+
raise SystemExit(f"user '{args.name}' exists already.")
|
|
84
|
+
if not valid_username(args.name):
|
|
85
|
+
raise SystemExit(
|
|
86
|
+
f"invalid user name: no blanks around it, no ':', at most {MAX_USERNAME_LENGTH} characters."
|
|
87
|
+
)
|
|
88
|
+
users[args.name] = {"password-hash": hash_password(_read_password(args)), "role": args.role}
|
|
89
|
+
else:
|
|
90
|
+
if args.name not in users:
|
|
91
|
+
raise SystemExit(f"unknown user '{args.name}'.")
|
|
92
|
+
if args.command == "passwd":
|
|
93
|
+
users[args.name]["password-hash"] = hash_password(_read_password(args))
|
|
94
|
+
elif args.command == "role":
|
|
95
|
+
if args.role != "admin":
|
|
96
|
+
_keep_an_admin(users, args.name)
|
|
97
|
+
users[args.name]["role"] = args.role
|
|
98
|
+
elif args.command == "remove":
|
|
99
|
+
_keep_an_admin(users, args.name)
|
|
100
|
+
del users[args.name]
|
|
101
|
+
UsersFile.write(args.file, users)
|
|
102
|
+
print(f"{args.command}: {args.name} - done.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|