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.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,1237 @@
1
+ """HTTPS server for the rest api and the web page (python standard library only - runs on every Raspberry Pi)."""
2
+
3
+ import hashlib
4
+ import http.server
5
+ import json
6
+ import logging
7
+ import math
8
+ import os
9
+ import re
10
+ import socketserver
11
+ import ssl
12
+ import threading
13
+ import time
14
+ import urllib.parse
15
+ from collections.abc import Callable, Mapping, Sequence
16
+ from typing import Any, Protocol
17
+
18
+ from tinyjukebox.backup import Incomplete
19
+ from tinyjukebox.backups import Backups
20
+ from tinyjukebox.control import BoxControl, CatalogControl
21
+ from tinyjukebox.core import unexpected
22
+ from tinyjukebox.core.logging import get_child
23
+ from tinyjukebox.data.targets import Targets
24
+ from tinyjukebox.errors import (
25
+ ApiError,
26
+ BadRequest,
27
+ Conflict,
28
+ Forbidden,
29
+ LengthRequired,
30
+ MethodNotAllowed,
31
+ NotFound,
32
+ PayloadTooLarge,
33
+ TooManyRequests,
34
+ Unauthorized,
35
+ )
36
+ from tinyjukebox.restore import Restore
37
+ from tinyjukebox.sync import acting_as
38
+ from tinyjukebox.web.auth import (
39
+ MIN_PASSWORD_LENGTH,
40
+ ROLES,
41
+ LoginThrottle,
42
+ Session,
43
+ Sessions,
44
+ UsersFile,
45
+ has_role,
46
+ hash_password,
47
+ valid_username,
48
+ )
49
+ from tinyjukebox.web.certificates import create_ssl_context, ensure_certificate
50
+ from tinyjukebox.web.logbuffer import LogBuffer
51
+ from tinyjukebox.web.uploads import Readable, Uploads
52
+
53
+ __all__ = ["DEFAULTS", "MAX_CONNECTIONS", "NO_CONTENT", "Request", "Response", "RestApi", "Route", "Writable"]
54
+
55
+
56
+ class Writable(Protocol):
57
+ """The part of the response stream (``wfile``) that event streams use."""
58
+
59
+ def write(self, data: bytes, /) -> int: ...
60
+
61
+ def flush(self) -> None: ...
62
+
63
+
64
+ WEB_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
65
+ STATIC_FILES = {
66
+ "/": ("index.html", "text/html; charset=utf-8"),
67
+ "/index.html": ("index.html", "text/html; charset=utf-8"),
68
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
69
+ "/style.css": ("style.css", "text/css; charset=utf-8"),
70
+ "/favicon.svg": ("favicon.svg", "image/svg+xml"),
71
+ }
72
+ # the texts of the web page, one file per language (index.html and app.js have none of their own)
73
+ STATIC_FILES.update(
74
+ (f"/texts/{name}", (os.path.join("texts", name), "application/json; charset=utf-8"))
75
+ for name in sorted(os.listdir(os.path.join(WEB_FOLDER, "texts")))
76
+ if name.endswith(".json")
77
+ )
78
+ # the help: the documents of docs/user as pages, made by scripts/help/build.py - without a login, as the page itself
79
+ HELP_TYPES = {
80
+ ".html": "text/html; charset=utf-8",
81
+ ".css": "text/css; charset=utf-8",
82
+ ".jpg": "image/jpeg",
83
+ ".svg": "image/svg+xml",
84
+ }
85
+ STATIC_FILES.update(
86
+ (f"/help/{name}", (os.path.join("help", *name.split("/")), HELP_TYPES[os.path.splitext(name)[1]]))
87
+ for name in sorted(
88
+ os.path.relpath(os.path.join(folder, file), os.path.join(WEB_FOLDER, "help")).replace(os.sep, "/")
89
+ for folder, _, files in os.walk(os.path.join(WEB_FOLDER, "help"))
90
+ for file in files
91
+ if os.path.splitext(file)[1] in HELP_TYPES
92
+ )
93
+ )
94
+ QUIET_PATHS = ("/api/status", "/api/logs", "/api/events", "/api/box/hardware")
95
+ SESSION_COOKIE = "tinyjukebox_session"
96
+ IDLE_HEADER = "X-TinyJukebox-Idle" # the page: seconds since the user did something last (see Sessions.get)
97
+ CSRF_HEADER = "X-TinyJukebox-Request" # cross site requests cannot set custom headers without a cors preflight
98
+ MAX_JSON_BODY = 64 * 1024
99
+ MAX_IMAGE_BODY = 10 * 2**20
100
+ MAX_DISCARD_BODY = 16 * 2**20 # unread request bodies up to this size are read and discarded
101
+ MAX_CONNECTIONS = 32 # concurrent connections - each one has its own thread
102
+ EVENT_INTERVAL = 0.2 # seconds between two checks of an event stream for changes
103
+ STATUS_REFRESH = 5.0 # an event stream sends the status at least this often (playback position, keeps the line open)
104
+
105
+ SECURITY_HEADERS = {
106
+ "Strict-Transport-Security": "max-age=31536000",
107
+ "X-Content-Type-Options": "nosniff",
108
+ "X-Frame-Options": "DENY",
109
+ "Referrer-Policy": "no-referrer",
110
+ "Content-Security-Policy": "default-src 'self'; img-src 'self' data: blob:; object-src 'none'; "
111
+ "frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
112
+ }
113
+
114
+ DEFAULT_FOLDER = os.path.join("~", ".config", "tinyjukebox")
115
+ DEFAULTS: dict[str, Any] = {
116
+ "host": "0.0.0.0", # noqa: S104 - the web page is meant to be reached from the local network
117
+ "port": 8443,
118
+ "certificate": os.path.join(DEFAULT_FOLDER, "tinyjukebox.crt"),
119
+ "private-key": os.path.join(DEFAULT_FOLDER, "tinyjukebox.key"),
120
+ "users-file": os.path.join(DEFAULT_FOLDER, "users.yaml"),
121
+ "session-timeout": 7 * 24 * 3600,
122
+ "idle-timeout": 30 * 60,
123
+ "max-upload-size": 500,
124
+ "hostnames": [],
125
+ }
126
+ PATH_KEYS = ["certificate", "private-key", "users-file"]
127
+
128
+
129
+ class Request:
130
+ """What a route handler gets: path parameters, query, session and the request body."""
131
+
132
+ def __init__(
133
+ self, handler: "RequestHandler", params: dict[str, str], session: Session | None, token: str | None
134
+ ) -> None:
135
+ self._handler = handler
136
+ self.params = params
137
+ self.session = session
138
+ self.token = token
139
+ self.query = urllib.parse.parse_qs(urllib.parse.urlsplit(handler.path).query)
140
+ self.client_address = str(handler.client_address[0])
141
+ self.path = urllib.parse.urlsplit(handler.path).path
142
+ self.method = str(handler.command)
143
+ self.headers = handler.headers
144
+ self.context: dict[str, Any] = {} # what a guard found out, e.g. the box that asks
145
+
146
+ def query_flag(self, name: str) -> bool:
147
+ return self.query.get(name, ["0"])[-1].lower() in ("1", "true", "yes")
148
+
149
+ def query_value(self, name: str, default: str) -> str:
150
+ return self.query.get(name, [default])[-1]
151
+
152
+ def content_length(self) -> int:
153
+ if "chunked" in self._handler.headers.get("Transfer-Encoding", "").lower():
154
+ raise LengthRequired("chunked transfer encoding is not supported - send a Content-Length.")
155
+ try:
156
+ length = int(self._handler.headers.get("Content-Length", 0))
157
+ except ValueError:
158
+ raise BadRequest("invalid Content-Length.") from None
159
+ if length < 0:
160
+ raise BadRequest("invalid Content-Length.")
161
+ return length
162
+
163
+ def json(self, limit: int = MAX_JSON_BODY) -> dict[str, Any]:
164
+ length = self.content_length()
165
+ if length > limit:
166
+ raise PayloadTooLarge("request body too large.")
167
+ body = self._handler.rfile.read(length) if length else b""
168
+ self._handler.body_consumed = True
169
+ if not body:
170
+ return {}
171
+ try:
172
+ data = json.loads(body.decode("utf-8"))
173
+ except (ValueError, UnicodeDecodeError):
174
+ raise BadRequest("request body must be valid json.") from None
175
+ if not isinstance(data, dict):
176
+ raise BadRequest("request body must be a json object.")
177
+ return data
178
+
179
+ @property
180
+ def stream(self) -> Readable:
181
+ self._handler.body_consumed = True
182
+ return self._handler.rfile
183
+
184
+ def body(self) -> "Body":
185
+ """The body as a stream of its length, for a body that is read while it arrives."""
186
+ return Body(self, self.content_length())
187
+
188
+ def close_connection(self) -> None:
189
+ """The connection is closed after the answer: the body was not read to its end."""
190
+ self._handler.close_connection = True
191
+
192
+ def save(self, filename: str) -> str:
193
+ """Store the body under ``filename`` and return its sha256 checksum."""
194
+ remaining = self.content_length()
195
+ stream = self.stream
196
+ digest = hashlib.sha256()
197
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
198
+ with open(filename, "wb") as outfile:
199
+ while remaining > 0:
200
+ chunk = stream.read(min(remaining, 1 << 16))
201
+ if not chunk:
202
+ raise BadRequest("the request ended early.")
203
+ outfile.write(chunk)
204
+ digest.update(chunk)
205
+ remaining -= len(chunk)
206
+ outfile.flush()
207
+ os.fsync(outfile.fileno())
208
+ return digest.hexdigest()
209
+
210
+
211
+ class Response:
212
+ def __init__(
213
+ self,
214
+ status: int = 200,
215
+ data: Any = None,
216
+ body: bytes | None = None,
217
+ content_type: str = "application/json",
218
+ headers: Mapping[str, str] | None = None,
219
+ stream: Callable[[Writable], None] | None = None,
220
+ file: str | None = None,
221
+ ) -> None:
222
+ """``stream``: writes the body itself until it returns (no length - the connection is closed afterwards).
223
+ ``file``: the body is this file; the client may ask for the rest of it (``Range: bytes=N-``)."""
224
+ self.status = status
225
+ self.stream = stream
226
+ self.file = file
227
+ if file is not None:
228
+ content_type = "application/octet-stream"
229
+ self.headers: dict[str, str] = dict(headers or {})
230
+ if body is None and data is not None:
231
+ body = json.dumps(data).encode("utf-8")
232
+ self.body = body if body is not None else b""
233
+ if self.body or stream is not None or file is not None or status != 204:
234
+ self.headers.setdefault("Content-Type", content_type)
235
+
236
+
237
+ NO_CONTENT = Response(204)
238
+
239
+
240
+ class Body:
241
+ """The body of a request, ``size`` bytes, read while it arrives (:meth:`Request.body`)."""
242
+
243
+ def __init__(self, request: Request, size: int) -> None:
244
+ self._request = request
245
+ self._stream = request.stream
246
+ self.size = size
247
+ self.left = size
248
+
249
+ def read(self, size: int = -1) -> bytes:
250
+ if self.left <= 0:
251
+ return b""
252
+ data = self._stream.read(self.left if size < 0 else min(size, self.left))
253
+ self.left -= len(data)
254
+ if not data:
255
+ raise BadRequest("the request ended early.")
256
+ return data
257
+
258
+ def finish(self) -> None:
259
+ """After an answer that does not need the rest: a small rest is read, otherwise the connection is closed -
260
+ an unread body would be taken for the next request."""
261
+ try:
262
+ if self.left > MAX_DISCARD_BODY:
263
+ raise BadRequest("too much is left")
264
+ while self.left > 0:
265
+ self.read(min(self.left, 1 << 16))
266
+ except BadRequest:
267
+ self._request.close_connection()
268
+
269
+
270
+ Handler = Callable[[Request], Response]
271
+
272
+
273
+ class Route:
274
+ def __init__(self, method: str, pattern: str, handler: Handler, role: str | None) -> None:
275
+ self.method = method
276
+ self.regex = re.compile("^" + re.sub(r"{(\w+)}", r"(?P<\1>[^/]+)", pattern) + "$")
277
+ self.handler = handler
278
+ self.role = role # None: no login needed; GUARDED: the guard of the path decides
279
+
280
+
281
+ GUARDED = "guarded"
282
+ """Role of a route that is not for users of the page: the guard of its path decides who may ask (``guards`` of
283
+ :class:`RestApi`)."""
284
+
285
+
286
+ class RequestHandler(http.server.BaseHTTPRequestHandler):
287
+ server_version = "TinyJukebox"
288
+ sys_version = ""
289
+ protocol_version = "HTTP/1.1"
290
+ api: "RestApi" # set by RestApi
291
+ timeout = 60 # seconds without data from the client - also for uploads
292
+ disable_nagle_algorithm = True # headers and body are written separately - no 40 ms delayed-ack stall
293
+ body_consumed = False
294
+
295
+ def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
296
+ # the web page polls these every second (or more often) - successful polls would only flood the log
297
+ path = urllib.parse.urlsplit(self.path).path
298
+ if path in QUIET_PATHS and str(code).startswith("2"):
299
+ return
300
+ super().log_request(code, size)
301
+
302
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - signature of the base class
303
+ self.api.logger.debug("%s - %s", self.client_address[0], format % args)
304
+
305
+ def do_GET(self) -> None:
306
+ self._dispatch("GET")
307
+
308
+ def do_POST(self) -> None:
309
+ self._dispatch("POST")
310
+
311
+ def do_PUT(self) -> None:
312
+ self._dispatch("PUT")
313
+
314
+ def do_PATCH(self) -> None:
315
+ self._dispatch("PATCH")
316
+
317
+ def do_DELETE(self) -> None:
318
+ self._dispatch("DELETE")
319
+
320
+ def _dispatch(self, method: str) -> None:
321
+ self.body_consumed = False
322
+ path = urllib.parse.urlsplit(self.path).path
323
+ try:
324
+ if method == "GET" and path in STATIC_FILES:
325
+ response = self._static(*STATIC_FILES[path])
326
+ else:
327
+ response = self.api.handle(self, method, path)
328
+ except ApiError as e:
329
+ response = Response(e.status, dict({"error": e.message}, **e.details))
330
+ if isinstance(e, TooManyRequests):
331
+ response.headers["Retry-After"] = str(e.details.get("retry_after", 60))
332
+ except Exception as error:
333
+ self.api.logger.exception("%s %s failed", method, path)
334
+ unexpected.report(error, f"request {method} {path}")
335
+ response = Response(500, {"error": "internal error."})
336
+ self._send(response)
337
+
338
+ def _request_has_body(self) -> bool:
339
+ try:
340
+ return int(self.headers.get("Content-Length", 0) or 0) > 0 or "Transfer-Encoding" in self.headers
341
+ except ValueError:
342
+ return True
343
+
344
+ def _discard_body(self) -> None:
345
+ """An unread request body would be taken for the next request. Small bodies are read (closing the connection
346
+ with unread data would reset it before the client reads the response), otherwise the connection is closed."""
347
+ try:
348
+ length = int(self.headers.get("Content-Length", 0) or 0)
349
+ except ValueError:
350
+ length = -1
351
+ if "Transfer-Encoding" not in self.headers and 0 <= length <= MAX_DISCARD_BODY:
352
+ self.rfile.read(length)
353
+ else:
354
+ self.close_connection = True
355
+
356
+ def _static(self, filename: str, content_type: str) -> Response:
357
+ with open(os.path.join(WEB_FOLDER, filename), "rb") as f:
358
+ return Response(200, body=f.read(), content_type=content_type, headers={"Cache-Control": "no-cache"})
359
+
360
+ def _send(self, response: Response) -> None:
361
+ if not self.body_consumed and self._request_has_body():
362
+ self._discard_body()
363
+ status = response.status
364
+ part = self._file_part(response.file) if response.file is not None else None
365
+ if part is not None and 0 < part[1] < part[0] and status == 200:
366
+ status = 206 # the rest of the file
367
+ self.send_response(status)
368
+ for key, value in SECURITY_HEADERS.items():
369
+ self.send_header(key, value)
370
+ response.headers.setdefault("Cache-Control", "no-store")
371
+ for key, value in response.headers.items():
372
+ self.send_header(key, value)
373
+ if response.stream is not None:
374
+ self.close_connection = True
375
+ self.send_header("Connection", "close")
376
+ self.end_headers()
377
+ response.stream(self.wfile)
378
+ return
379
+ if response.file is not None and part is not None:
380
+ self._send_file(response.file, *part)
381
+ return
382
+ self.send_header("Content-Length", str(len(response.body)))
383
+ if self.close_connection:
384
+ self.send_header("Connection", "close")
385
+ self.end_headers()
386
+ self.wfile.write(response.body)
387
+
388
+ def _file_part(self, filename: str) -> tuple[int, int]:
389
+ """(size of the file, where the answer begins): at the start, or where the client asks for the rest of it
390
+ (``Range: bytes=N-``)."""
391
+ size = os.path.getsize(filename)
392
+ match = re.fullmatch(r"bytes=(\d+)-", self.headers.get("Range", ""))
393
+ return size, min(int(match.group(1)), size) if match else 0
394
+
395
+ def _send_file(self, filename: str, size: int, start: int) -> None:
396
+ """The headers so far are sent; now the file, or the rest of it that the client asks for: 206 with its
397
+ range. A client that has the whole file already gets 200 with an empty range and no body - curl and a box
398
+ from 0.45 on take that for "nothing left", an older box fetches the file once more."""
399
+ if start:
400
+ self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
401
+ self.send_header("Content-Length", str(size - start))
402
+ self.end_headers()
403
+ with open(filename, "rb") as infile:
404
+ infile.seek(start)
405
+ while chunk := infile.read(1 << 16):
406
+ self.wfile.write(chunk)
407
+
408
+
409
+ class _Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
410
+ daemon_threads = True
411
+ allow_reuse_address = True
412
+
413
+ def __init__(
414
+ self,
415
+ address: tuple[str, int],
416
+ handler: type[RequestHandler],
417
+ ssl_context: ssl.SSLContext,
418
+ max_connections: int = MAX_CONNECTIONS,
419
+ ) -> None:
420
+ self._ssl_context = ssl_context
421
+ self._connections = threading.BoundedSemaphore(max_connections)
422
+ super().__init__(address, handler)
423
+
424
+ def process_request(self, request: Any, client_address: Any) -> None:
425
+ # one thread per connection - but not without limit (slow or idle clients would exhaust the box)
426
+ if not self._connections.acquire(blocking=False):
427
+ self.shutdown_request(request)
428
+ return
429
+ try:
430
+ super().process_request(request, client_address)
431
+ except Exception:
432
+ self._connections.release()
433
+ raise
434
+
435
+ def process_request_thread(self, request: Any, client_address: Any) -> None:
436
+ try:
437
+ super().process_request_thread(request, client_address)
438
+ finally:
439
+ self._connections.release()
440
+
441
+ def finish_request(self, request: Any, client_address: Any) -> None:
442
+ # tls handshake in the worker thread - a slow client must not block the accept loop
443
+ try:
444
+ request.settimeout(RequestHandler.timeout)
445
+ tls_socket = self._ssl_context.wrap_socket(request, server_side=True)
446
+ except (ssl.SSLError, OSError):
447
+ return
448
+ try:
449
+ super().finish_request(tls_socket, client_address)
450
+ finally:
451
+ tls_socket.close()
452
+
453
+ def handle_error(self, request: Any, client_address: Any) -> None:
454
+ pass # connection errors of single clients are not interesting
455
+
456
+
457
+ def _without_position(status: dict[str, Any]) -> dict[str, Any]:
458
+ """``status`` without the playback position (``album/playback/position``, ``spotify/track/position`` and the
459
+ stored position ``tag/activity/track_time``) - it changes all the time while playing."""
460
+
461
+ def without(entry: Any, key: str, inner: str) -> Any:
462
+ if not isinstance(entry, dict) or not isinstance(entry.get(key), dict):
463
+ return entry
464
+ return {**entry, key: {k: v for k, v in entry[key].items() if k != inner}}
465
+
466
+ result = {**status, "album": without(status.get("album"), "playback", "position"),
467
+ "tag": without(status.get("tag"), "activity", "track_time")} # fmt: skip
468
+ if "spotify" in status:
469
+ result["spotify"] = without(status["spotify"], "track", "position")
470
+ return result
471
+
472
+
473
+ def _write_event(out: Writable, event: str, data: Any) -> None:
474
+ out.write(f"event: {event}\ndata: {json.dumps(data)}\n\n".encode())
475
+ out.flush()
476
+
477
+
478
+ class RestApi:
479
+ """The rest api and the web page (section ``rest-api`` of the config, see :data:`DEFAULTS`)."""
480
+
481
+ def __init__(
482
+ self,
483
+ api_config: Mapping[str, Any],
484
+ control: CatalogControl,
485
+ targets: Targets,
486
+ logger: logging.Logger,
487
+ log_buffer: LogBuffer | None = None,
488
+ routes: Sequence[Route] = (),
489
+ guards: Mapping[str, Callable[[Request], None]] | None = None,
490
+ mode: str = "box",
491
+ ) -> None:
492
+ """``log_buffer``: :class:`LogBuffer` that is already attached to the logger - otherwise the api attaches
493
+ its own while it is running.
494
+
495
+ ``routes``: further routes, e.g. the interface for the boxes of a library. ``guards``: for the paths that
496
+ begin with a key, its guard decides who may ask instead of the login - it raises an error for a request that
497
+ may not, also for a path that does not exist. ``mode``: what the page is the page of (``box``/``library``).
498
+ """
499
+ self.logger = get_child(logger, type(self).__name__)
500
+ self._root_logger = logger
501
+ self._config: dict[str, Any] = dict(DEFAULTS, **api_config)
502
+ for key in PATH_KEYS:
503
+ self._config[key] = os.path.expanduser(self._config[key])
504
+ self._own_log_buffer = log_buffer is None
505
+ self.log_buffer = log_buffer if log_buffer is not None else LogBuffer()
506
+ self.control = control
507
+ self.uploads = Uploads(targets, control.playlist_filename, int(self._config["max-upload-size"] * 2**20), logger)
508
+ self.users = UsersFile(self._config["users-file"], self.logger)
509
+ self.sessions = Sessions(self._config["session-timeout"], idle_timeout=self._config["idle-timeout"])
510
+ self.throttle = LoginThrottle()
511
+ self.mode = mode
512
+ # the page of the library has a cookie of its own: a box and the library on one host name (the PC, with two
513
+ # ports) would otherwise log each other out - a cookie belongs to the host, whatever the port
514
+ self._cookie = SESSION_COOKIE if mode == "box" else f"{SESSION_COOKIE}_{mode}"
515
+ self._guards = dict(guards or {})
516
+ self._routes = [*self._create_routes(), *routes]
517
+ self._server: _Server | None = None
518
+ self._thread: threading.Thread | None = None
519
+ self._stopping = threading.Event() # ends the event streams
520
+ self.fingerprint: str | None = None
521
+
522
+ # --- routes ------------------------------------------------------------------------------------------------
523
+
524
+ def _create_routes(self) -> list[Route]:
525
+ c = self.control
526
+ u = self.uploads
527
+
528
+ def done(action: Callable[[], object]) -> Response:
529
+ action()
530
+ return NO_CONTENT
531
+
532
+ routes = [
533
+ Route("POST", "/api/login", self._login, None),
534
+ Route("POST", "/api/logout", self._logout, None),
535
+ Route("GET", "/api/identity", self._identity, None),
536
+ Route("GET", "/api/session", self._session, "user"),
537
+ Route("PUT", "/api/session/password", self._change_own_password, "user"),
538
+ Route("GET", "/api/users", self._list_users, "admin"),
539
+ Route("POST", "/api/users", self._add_user, "admin"),
540
+ Route("PUT", "/api/users/{name}/password", self._set_password, "admin"),
541
+ Route("DELETE", "/api/users/{name}", self._remove_user, "admin"),
542
+ Route("GET", "/api/status", lambda r: Response(data=c.status()), "user"),
543
+ Route("GET", "/api/events", self._events, "user"),
544
+ Route("GET", "/api/tags", lambda r: Response(data=c.list_tags(r.query_flag("unmapped"))), "user"),
545
+ Route("POST", "/api/tags", self._create_tag, "admin"),
546
+ Route("GET", "/api/logs", self._logs, "admin"),
547
+ Route("GET", "/api/tags/{uid}", lambda r: Response(data=c.get_tag(r.params["uid"])), "user"),
548
+ Route(
549
+ "PATCH",
550
+ "/api/tags/{uid}",
551
+ lambda r: Response(data=c.set_tag_name(r.params["uid"], r.json().get("name"))),
552
+ "admin",
553
+ ),
554
+ Route(
555
+ "PUT",
556
+ "/api/tags/{uid}/album",
557
+ lambda r: Response(data=c.link_tag_album(r.params["uid"], r.json().get("album_id"))),
558
+ "admin",
559
+ ),
560
+ Route("DELETE", "/api/tags/{uid}/album", lambda r: Response(data=c.unlink_tag(r.params["uid"])), "admin"),
561
+ Route(
562
+ "PUT",
563
+ "/api/tags/{uid}/radio",
564
+ lambda r: Response(data=c.link_tag_radio(r.params["uid"], r.json().get("radio_id"))),
565
+ "admin",
566
+ ),
567
+ Route("DELETE", "/api/tags/{uid}/radio", lambda r: Response(data=c.unlink_tag(r.params["uid"])), "admin"),
568
+ Route(
569
+ "PUT",
570
+ "/api/tags/{uid}/spotify",
571
+ lambda r: Response(data=c.link_tag_spotify(r.params["uid"], r.json().get("spotify_id"))),
572
+ "admin",
573
+ ),
574
+ Route("DELETE", "/api/tags/{uid}/spotify", lambda r: Response(data=c.unlink_tag(r.params["uid"])), "admin"),
575
+ Route("DELETE", "/api/tags/{uid}", lambda r: done(lambda: c.remove_tag(r.params["uid"])), "admin"),
576
+ Route("GET", "/api/albums", lambda r: Response(data=c.list_albums(r.query_flag("unmapped"))), "user"),
577
+ Route("POST", "/api/albums", self._add_album, "admin"),
578
+ Route("GET", "/api/albums/new", lambda r: Response(data=c.new_albums()), "admin"),
579
+ Route(
580
+ "POST",
581
+ "/api/albums/new",
582
+ lambda r: Response(data=c.add_new_albums(r.json().get("titles"))),
583
+ "admin",
584
+ ),
585
+ Route("GET", "/api/albums/{id}", lambda r: Response(data=c.get_album(r.params["id"])), "user"),
586
+ Route(
587
+ "PATCH",
588
+ "/api/albums/{id}",
589
+ lambda r: Response(data=c.update_album(r.params["id"], r.json().get("title"))),
590
+ "admin",
591
+ ),
592
+ Route("GET", "/api/albums/{id}/cover", self._cover, "user"),
593
+ Route("PUT", "/api/albums/{id}/cover", self._set_cover, "admin"),
594
+ Route(
595
+ "POST",
596
+ "/api/albums/{id}/playlist",
597
+ lambda r: Response(data=c.rebuild_playlist(r.params["id"])),
598
+ "admin",
599
+ ),
600
+ Route(
601
+ "DELETE",
602
+ "/api/albums/{id}",
603
+ lambda r: done(lambda: c.delete_album(r.params["id"], r.query_flag("delete_files"))),
604
+ "admin",
605
+ ),
606
+ Route("GET", "/api/radios", lambda r: Response(data=c.list_radios()), "user"),
607
+ Route("POST", "/api/radios", self._add_radio, "admin"),
608
+ Route("GET", "/api/radios/{id}", lambda r: Response(data=c.get_radio(r.params["id"])), "user"),
609
+ Route("PATCH", "/api/radios/{id}", self._update_radio, "admin"),
610
+ Route("DELETE", "/api/radios/{id}", lambda r: done(lambda: c.delete_radio(r.params["id"])), "admin"),
611
+ Route("GET", "/api/radios/{id}/cover", self._radio_cover, "user"),
612
+ Route("PUT", "/api/radios/{id}/cover", self._set_radio_cover, "admin"),
613
+ Route("GET", "/api/spotify", lambda r: Response(data=c.list_spotify()), "user"),
614
+ Route("POST", "/api/spotify", self._add_spotify, "admin"),
615
+ Route(
616
+ "POST", "/api/spotify-lookup", lambda r: Response(data=c.look_up_spotify(r.json().get("link"))), "admin"
617
+ ),
618
+ Route("GET", "/api/spotify/{id}", lambda r: Response(data=c.get_spotify(r.params["id"])), "user"),
619
+ Route("PATCH", "/api/spotify/{id}", self._update_spotify, "admin"),
620
+ Route("DELETE", "/api/spotify/{id}", lambda r: done(lambda: c.delete_spotify(r.params["id"])), "admin"),
621
+ Route("GET", "/api/spotify/{id}/cover", self._spotify_cover, "user"),
622
+ Route("PUT", "/api/spotify/{id}/cover", self._set_spotify_cover, "admin"),
623
+ Route("GET", "/api/activities", lambda r: Response(data=c.list_activities()), "user"),
624
+ Route("POST", "/api/activities/rebuild", lambda r: Response(data=c.rebuild_activities()), "admin"),
625
+ Route("POST", "/api/sanity-check", lambda r: Response(data=c.sanity_checks()), "admin"),
626
+ Route("GET", "/api/backup", self._backup, "admin"),
627
+ Route("GET", "/api/uploads", lambda r: Response(data=u.list()), "admin"),
628
+ Route("POST", "/api/uploads", lambda r: Response(201, u.create(r.json().get("name"))), "admin"),
629
+ Route("GET", "/api/uploads/{id}", lambda r: Response(data=u.get(r.params["id"])), "admin"),
630
+ Route("DELETE", "/api/uploads/{id}", lambda r: done(lambda: u.abort(r.params["id"])), "admin"),
631
+ Route("PUT", "/api/uploads/{id}/files/{filename}", self._upload_file, "admin"),
632
+ Route(
633
+ "DELETE",
634
+ "/api/uploads/{id}/files/{filename}",
635
+ lambda r: done(lambda: u.remove_file(r.params["id"], r.params["filename"])),
636
+ "admin",
637
+ ),
638
+ Route("POST", "/api/uploads/{id}/finish", self._finish_upload, "admin"),
639
+ ]
640
+ # the backups that are made here by a schedule (tinyjukebox.backups); GET /api/backup sends one at once
641
+ routes += self._backups_routes("/api/backups" if isinstance(c, BoxControl) else "/api/library/backups")
642
+ routes += self._restore_routes()
643
+ if isinstance(c, BoxControl):
644
+ routes += self._box_routes(c)
645
+ return routes
646
+
647
+ def _restore_routes(self) -> list[Route]:
648
+ """Putting a backup back (tinyjukebox.restore): an upload, or a backup of the folders of the backups, is
649
+ unpacked beside the data; when it is confirmed the program starts again with it."""
650
+
651
+ def restore() -> Restore:
652
+ if self.control.restore is None:
653
+ raise NotFound("no backup can be put back here.")
654
+ return self.control.restore
655
+
656
+ def status() -> dict[str, Any]:
657
+ c = self.control
658
+ # a box with a library: the library wins at the next synchronization - the page says so before
659
+ attached = isinstance(c, BoxControl) and c.sync is not None and c.sync.attached
660
+ return {**restore().status(), "kind": restore().kind, "library": attached}
661
+
662
+ def upload(request: Request) -> Response:
663
+ body = request.body()
664
+ if not body.size:
665
+ raise BadRequest("the body must be a backup.", code="restore.incomplete", reason="nothing was sent")
666
+ try:
667
+ restore().stage(body, body.size) # type: ignore[arg-type]
668
+ except Incomplete as error:
669
+ body.finish()
670
+ raise BadRequest(f"no backup: {error}", code="restore.incomplete", reason=str(error)) from None
671
+ except Exception:
672
+ body.finish()
673
+ raise
674
+ return Response(data=status())
675
+
676
+ def stored(request: Request) -> Response:
677
+ if self.control.backups is None:
678
+ raise NotFound("no backups are kept here.")
679
+ name = request.json().get("name")
680
+ restore().stage_in_background(self.control.backups.path(str(name)))
681
+ return Response(202, status())
682
+
683
+ def confirm(request: Request) -> Response:
684
+ restore().confirm()
685
+ return Response(202, status())
686
+
687
+ def discard(request: Request) -> Response:
688
+ restore().discard()
689
+ return Response(data=status())
690
+
691
+ return [
692
+ Route("GET", "/api/restore", lambda r: Response(data=status()), "admin"),
693
+ Route("PUT", "/api/restore", upload, "admin"),
694
+ Route("POST", "/api/restore", stored, "admin"),
695
+ Route("POST", "/api/restore/confirm", confirm, "admin"),
696
+ Route("DELETE", "/api/restore", discard, "admin"),
697
+ ]
698
+
699
+ def _backups_routes(self, prefix: str) -> list[Route]:
700
+ """The schedules of the backups and the backups that are kept: the same for a box and the library."""
701
+
702
+ def backups() -> Backups:
703
+ if self.control.backups is None:
704
+ raise NotFound("no backups are kept here.")
705
+ return self.control.backups
706
+
707
+ def ask(request: Request) -> Response:
708
+ backups().ask(bool(request.json().get("albums", False)))
709
+ return Response(202, backups().status())
710
+
711
+ def stored(request: Request) -> Response:
712
+ name = request.params["name"]
713
+ headers = {"Content-Disposition": f'attachment; filename="{name}"'}
714
+ return Response(200, headers=headers, file=backups().path(name))
715
+
716
+ def remove(request: Request) -> Response:
717
+ backups().remove(request.params["name"])
718
+ return NO_CONTENT
719
+
720
+ return [
721
+ Route("GET", prefix, lambda r: Response(data=backups().status()), "admin"),
722
+ Route("PUT", prefix, lambda r: Response(data=backups().set(r.json())), "admin"),
723
+ Route("POST", prefix, ask, "admin"),
724
+ Route("GET", f"{prefix}/{{name}}", stored, "admin"),
725
+ Route("DELETE", f"{prefix}/{{name}}", remove, "admin"),
726
+ ]
727
+
728
+ def _box_routes(self, c: BoxControl) -> list[Route]:
729
+ """What only a box has: virtual reader, radio test, player, volume, headset, buttons."""
730
+
731
+ def done(action: Callable[[], object]) -> Response:
732
+ action()
733
+ return NO_CONTENT
734
+
735
+ return [
736
+ Route("GET", "/api/box", lambda r: Response(data=c.library_info()), "user"),
737
+ Route("PUT", "/api/box/library", self._set_library, "admin"),
738
+ Route("DELETE", "/api/box/library", lambda r: Response(data=c.unlink_library()), "admin"),
739
+ Route("PUT", "/api/box/name", lambda r: Response(data=c.set_box_name(r.json().get("name"))), "admin"),
740
+ # what was played on this box is for everybody with a login on its page, as in the library
741
+ Route("GET", "/api/box/statistics", lambda r: Response(data=c.statistics()), "user"),
742
+ Route("GET", "/api/box/system", lambda r: Response(data=c.system_info()), "admin"),
743
+ Route("GET", "/api/box/hardware", lambda r: Response(data=c.hardware_info()), "admin"),
744
+ Route("POST", "/api/box/system/{command}", self._system_command, "admin"),
745
+ Route("POST", "/api/rfid", lambda r: done(lambda: c.place_tag(r.json().get("uid"))), "user"),
746
+ Route("DELETE", "/api/rfid", lambda r: done(c.remove_placed_tag), "user"),
747
+ Route("POST", "/api/radio-test", self._test_radio, "admin"),
748
+ Route("POST", "/api/spotify-test", self._test_spotify, "admin"),
749
+ Route("DELETE", "/api/spotify-test", lambda r: done(c.stop_radio_test), "admin"),
750
+ Route("GET", "/api/spotify-account", lambda r: Response(data=c.spotify_account()), "admin"),
751
+ Route(
752
+ "PUT",
753
+ "/api/spotify-account/key",
754
+ lambda r: Response(data=c.set_spotify_key(r.json().get("key"))),
755
+ "admin",
756
+ ),
757
+ Route(
758
+ "PUT",
759
+ "/api/spotify-account/speaker",
760
+ lambda r: Response(data=c.set_spotify_speaker(r.json().get("enabled"))),
761
+ "admin",
762
+ ),
763
+ Route("POST", "/api/spotify-account", lambda r: Response(202, c.connect_spotify()), "admin"),
764
+ Route("DELETE", "/api/spotify-account", lambda r: Response(data=c.disconnect_spotify()), "admin"),
765
+ Route("GET", "/api/spotify-player", lambda r: Response(data=c.spotify_player()), "admin"),
766
+ Route("POST", "/api/spotify-player", lambda r: Response(202, c.update_spotify_player()), "admin"),
767
+ Route("DELETE", "/api/radio-test", lambda r: done(c.stop_radio_test), "admin"),
768
+ Route("POST", "/api/player/next", lambda r: done(c.track_next), "user"),
769
+ Route("POST", "/api/player/prev", lambda r: done(c.track_prev), "user"),
770
+ Route("POST", "/api/player/restart", lambda r: done(c.restart_album), "user"),
771
+ Route("POST", "/api/player/pause", lambda r: done(c.pause), "user"),
772
+ Route("POST", "/api/player/play", lambda r: done(c.play), "user"),
773
+ Route("GET", "/api/volume", lambda r: Response(data=c.get_volume()), "user"),
774
+ Route("PUT", "/api/volume", lambda r: Response(data=c.set_volume(r.json().get("volume"))), "user"),
775
+ # start value and maximum (hearing protection): admins only
776
+ Route("PUT", "/api/volume/limits/{profile}", self._set_volume_limits, "admin"),
777
+ Route("GET", "/api/bluetooth", lambda r: Response(data=c.bluetooth_info()), "user"),
778
+ Route("POST", "/api/bluetooth/pair", lambda r: Response(202, c.bluetooth_pair()), "admin"),
779
+ Route("DELETE", "/api/bluetooth/device", lambda r: done(c.bluetooth_unpair), "admin"),
780
+ Route("POST", "/api/bluetooth/virtual", self._virtual_headset, "user"),
781
+ Route(
782
+ "POST",
783
+ "/api/buttons/{button}",
784
+ lambda r: done(lambda: c.press_button(r.params["button"], r.json().get("action", "press"))),
785
+ "user",
786
+ ),
787
+ ]
788
+
789
+ @property
790
+ def _box(self) -> BoxControl:
791
+ """The control of a box - for the handlers of the routes that only a box has."""
792
+ assert isinstance(self.control, BoxControl) # noqa: S101 - these routes exist for a box only
793
+ return self.control
794
+
795
+ def _login(self, request: Request) -> Response:
796
+ address = request.client_address
797
+ retry_after = self.throttle.retry_after(address)
798
+ if retry_after:
799
+ raise TooManyRequests("too many failed logins - try again later.", retry_after=retry_after)
800
+ data = request.json()
801
+ username, password = data.get("username"), data.get("password")
802
+ if not isinstance(username, str) or not isinstance(password, str):
803
+ raise BadRequest("username and password must be provided.")
804
+ role = self.users.authenticate(username, password)
805
+ if role is None:
806
+ self.throttle.failed(address)
807
+ self.logger.warning("failed login for '%s' from %s.", username, address)
808
+ raise Unauthorized("wrong username or password.")
809
+ self.throttle.succeeded(address)
810
+ token = self.sessions.create(username, role, self.users.credential(username) or "")
811
+ self.logger.info("'%s' (%s) logged in from %s.", username, role, address)
812
+ cookie = f"{self._cookie}={token}; Path=/; Secure; HttpOnly; SameSite=Strict"
813
+ return Response(
814
+ data={
815
+ "username": username,
816
+ "role": role,
817
+ "token": token,
818
+ "mode": self.mode,
819
+ "idle_timeout": self.sessions.idle_timeout,
820
+ },
821
+ headers={"Set-Cookie": cookie},
822
+ )
823
+
824
+ def _logout(self, request: Request) -> Response:
825
+ if request.token:
826
+ self.sessions.remove(request.token)
827
+ cookie = f"{self._cookie}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0"
828
+ return Response(204, headers={"Set-Cookie": cookie})
829
+
830
+ def _identity(self, request: Request) -> Response:
831
+ """Library or box, and which box - for the login page, so without a login."""
832
+ return Response(data={"mode": self.mode, **self.control.identity()})
833
+
834
+ def _session(self, request: Request) -> Response:
835
+ assert request.session is not None # noqa: S101 - routes with a role always have a session
836
+ return Response(
837
+ data={
838
+ "username": request.session.username,
839
+ "role": request.session.role,
840
+ "mode": self.mode,
841
+ "idle_timeout": self.sessions.idle_timeout,
842
+ }
843
+ )
844
+
845
+ def _list_users(self, request: Request) -> Response:
846
+ roles = self.users.roles()
847
+ return Response(data=[{"username": name, "role": roles[name]} for name in sorted(roles)])
848
+
849
+ @staticmethod
850
+ def _new_password_hash(data: dict[str, Any]) -> str:
851
+ password = data.get("password")
852
+ if not isinstance(password, str) or len(password) < MIN_PASSWORD_LENGTH:
853
+ raise BadRequest(
854
+ f"password must have at least {MIN_PASSWORD_LENGTH} characters.",
855
+ code="password.too_short",
856
+ min=MIN_PASSWORD_LENGTH,
857
+ )
858
+ return hash_password(password)
859
+
860
+ def _add_user(self, request: Request) -> Response:
861
+ assert request.session is not None # noqa: S101 - routes with a role always have a session
862
+ data = request.json()
863
+ username, role = data.get("username"), data.get("role", "user")
864
+ if not valid_username(username):
865
+ raise BadRequest("invalid user name.", code="user.invalid_name")
866
+ if role not in ROLES:
867
+ raise BadRequest(f"role must be one of {', '.join(ROLES)}.")
868
+ password_hash = self._new_password_hash(data)
869
+
870
+ def add(users: dict[str, dict[str, Any]]) -> None:
871
+ if username in users:
872
+ raise Conflict(f"user '{username}' exists already.", code="user.exists", name=username)
873
+ users[username] = {"password-hash": password_hash, "role": role}
874
+
875
+ self.users.change(add)
876
+ self.logger.info("'%s' added the user '%s' (%s).", request.session.username, username, role)
877
+ return Response(201, {"username": username, "role": role})
878
+
879
+ def _set_password(self, request: Request) -> Response:
880
+ assert request.session is not None # noqa: S101 - routes with a role always have a session
881
+ username = request.params["name"]
882
+ self._store_password(request, username, self._new_password_hash(request.json()))
883
+ self.logger.info("'%s' changed the password of '%s'.", request.session.username, username)
884
+ return NO_CONTENT
885
+
886
+ def _change_own_password(self, request: Request) -> Response:
887
+ """Every user may change the own password - with the current one, so that an open browser or a stolen
888
+ session does not suffice. Wrong current passwords count as failed logins."""
889
+ assert request.session is not None # noqa: S101 - routes with a role always have a session
890
+ username = request.session.username
891
+ address = request.client_address
892
+ retry_after = self.throttle.retry_after(address)
893
+ if retry_after:
894
+ raise TooManyRequests("too many failed logins - try again later.", retry_after=retry_after)
895
+ data = request.json()
896
+ current = data.get("current_password")
897
+ if not isinstance(current, str):
898
+ raise BadRequest("current_password must be provided.")
899
+ password_hash = self._new_password_hash(data)
900
+ # not Unauthorized: the session is still valid
901
+ if self.users.authenticate(username, current) is None:
902
+ self.throttle.failed(address)
903
+ self.logger.warning("'%s' entered a wrong current password from %s.", username, address)
904
+ raise Forbidden("wrong current password.")
905
+ self.throttle.succeeded(address)
906
+ self._store_password(request, username, password_hash)
907
+ self.logger.info("'%s' changed the own password.", username)
908
+ return NO_CONTENT
909
+
910
+ def _store_password(self, request: Request, username: str, password_hash: str) -> None:
911
+ def set_password(users: dict[str, dict[str, Any]]) -> None:
912
+ if username not in users:
913
+ raise NotFound(f"unknown user '{username}'.", code="user.unknown", name=username)
914
+ users[username]["password-hash"] = password_hash
915
+
916
+ self.users.change(set_password)
917
+ # whoever is logged in with the old password has to log in again - except the one who just changed it
918
+ self.sessions.remove_user(username, keep=request.token, credential=self.users.credential(username))
919
+
920
+ def _remove_user(self, request: Request) -> Response:
921
+ assert request.session is not None # noqa: S101 - routes with a role always have a session
922
+ username = request.params["name"]
923
+ # only admins get here - so at least one admin always remains
924
+ if username == request.session.username:
925
+ raise Conflict("you cannot remove yourself.", code="user.self")
926
+
927
+ def remove(users: dict[str, dict[str, Any]]) -> None:
928
+ if username not in users:
929
+ raise NotFound(f"unknown user '{username}'.", code="user.unknown", name=username)
930
+ del users[username]
931
+
932
+ self.users.change(remove)
933
+ self.sessions.remove_user(username)
934
+ self.logger.info("'%s' removed the user '%s'.", request.session.username, username)
935
+ return NO_CONTENT
936
+
937
+ def _create_tag(self, request: Request) -> Response:
938
+ data = request.json()
939
+ return Response(
940
+ 201,
941
+ self.control.create_tag(
942
+ data.get("uid"), data.get("name"), data.get("album_id"), data.get("radio_id"), data.get("spotify_id")
943
+ ),
944
+ )
945
+
946
+ def _add_radio(self, request: Request) -> Response:
947
+ data = request.json()
948
+ return Response(201, self.control.add_radio(data.get("name"), data.get("url")))
949
+
950
+ def _test_radio(self, request: Request) -> Response:
951
+ data = request.json()
952
+ return Response(202, self._box.test_radio(data.get("url"), data.get("name")))
953
+
954
+ def _test_spotify(self, request: Request) -> Response:
955
+ data = request.json()
956
+ return Response(202, self._box.test_spotify(data.get("id"), data.get("link")))
957
+
958
+ def _update_radio(self, request: Request) -> Response:
959
+ data = request.json()
960
+ return Response(data=self.control.update_radio(request.params["id"], data.get("name"), data.get("url")))
961
+
962
+ def _radio_cover(self, request: Request) -> Response:
963
+ data, mime = self.control.get_radio_cover(request.params["id"])
964
+ # the address names the image (page: "?v=<name>"), so a new cover is never the cached one
965
+ return Response(body=data, content_type=mime, headers={"Cache-Control": "private, max-age=300"})
966
+
967
+ def _set_radio_cover(self, request: Request) -> Response:
968
+ length = request.content_length()
969
+ if length > MAX_IMAGE_BODY:
970
+ raise PayloadTooLarge("image is too large.", code="image.too_large")
971
+ return Response(data=self.control.set_radio_cover(request.params["id"], request.stream.read(length)))
972
+
973
+ def _add_spotify(self, request: Request) -> Response:
974
+ data = request.json()
975
+ return Response(201, self.control.add_spotify(data.get("link"), data.get("name")))
976
+
977
+ def _update_spotify(self, request: Request) -> Response:
978
+ data = request.json()
979
+ return Response(data=self.control.update_spotify(request.params["id"], data.get("name"), data.get("link")))
980
+
981
+ def _spotify_cover(self, request: Request) -> Response:
982
+ data, mime = self.control.get_spotify_cover(request.params["id"])
983
+ return Response(body=data, content_type=mime, headers={"Cache-Control": "private, max-age=300"})
984
+
985
+ def _set_spotify_cover(self, request: Request) -> Response:
986
+ length = request.content_length()
987
+ if length > MAX_IMAGE_BODY:
988
+ raise PayloadTooLarge("image is too large.", code="image.too_large")
989
+ return Response(data=self.control.set_spotify_cover(request.params["id"], request.stream.read(length)))
990
+
991
+ def _set_volume_limits(self, request: Request) -> Response:
992
+ data = request.json()
993
+ return Response(
994
+ data=self._box.set_volume_limits(
995
+ request.params["profile"], data.get("default_volume"), data.get("max_volume")
996
+ )
997
+ )
998
+
999
+ def _set_library(self, request: Request) -> Response:
1000
+ data = request.json()
1001
+ return Response(data=self._box.set_library(data.get("url"), data.get("bootstrap")))
1002
+
1003
+ def _system_command(self, request: Request) -> Response:
1004
+ """A command of the tab "System": everything goes through the slot script."""
1005
+ assert request.session is not None # noqa: S101 - routes with a role always have a session
1006
+ arguments = request.json().get("arguments", [])
1007
+ by = f"web page, user {request.session.username}"
1008
+ return Response(data=self._box.system_command(request.params["command"], arguments, by))
1009
+
1010
+ def _virtual_headset(self, request: Request) -> Response:
1011
+ data = request.json()
1012
+ return Response(data=self._box.virtual_headset(data.get("connected"), data.get("button")))
1013
+
1014
+ def _backup(self, request: Request) -> Response:
1015
+ """The data as one archive (:mod:`tinyjukebox.backup`), written while it is sent: with ``albums=1`` it may be
1016
+ many gigabytes. If it fails on the way the archive is cut off, which its end shows."""
1017
+ backup = self.control.backup(request.query_flag("albums"))
1018
+
1019
+ def send(out: Writable) -> None:
1020
+ try:
1021
+ result = backup.write(out)
1022
+ except OSError as error:
1023
+ self.logger.warning("backup %s was not sent to its end: %r", backup.filename, error)
1024
+ return
1025
+ self.logger.info("backup %s sent: %d files, %d bytes", backup.filename, result["files"], result["bytes"])
1026
+
1027
+ headers = {"Content-Disposition": f'attachment; filename="{backup.filename}"'}
1028
+ return Response(200, content_type=backup.content_type, headers=headers, stream=send)
1029
+
1030
+ def _events(self, request: Request) -> Response:
1031
+ return Response(content_type="text/event-stream", stream=lambda out: self._stream_events(request.token, out))
1032
+
1033
+ def _stream_events(self, token: str | None, out: Writable) -> None:
1034
+ """Server-sent events until the client disconnects, the session ends or the api stops.
1035
+
1036
+ * ``status`` (as ``/api/status``) whenever it changes - the playback position does not count, the page
1037
+ interpolates it - and at least every :data:`STATUS_REFRESH` seconds,
1038
+ * ``log`` (admins only) with the id of the newest record whenever there are new log records.
1039
+
1040
+ Each stream checks for changes every :data:`EVENT_INTERVAL` seconds in its own (connection) thread.
1041
+ """
1042
+ last_key: Any = None
1043
+ last_sent = 0.0
1044
+ log_id = self.log_buffer.last_id
1045
+ try:
1046
+ while not self._stopping.is_set():
1047
+ session = self.sessions.get(token, idle=None) # the stream says nothing about the user
1048
+ role = self.users.session_role(session) if session is not None else None
1049
+ if role is None:
1050
+ break # logged out, expired, removed or a new password - the page reconnects and gets a 401
1051
+ status = self.control.status()
1052
+ key = _without_position(status)
1053
+ now = time.monotonic()
1054
+ if key != last_key or now - last_sent >= STATUS_REFRESH:
1055
+ _write_event(out, "status", status)
1056
+ last_key, last_sent = key, now
1057
+ if has_role(role, "admin") and self.log_buffer.last_id != log_id:
1058
+ log_id = self.log_buffer.last_id
1059
+ _write_event(out, "log", {"last_id": log_id})
1060
+ self._stopping.wait(EVENT_INTERVAL)
1061
+ except OSError: # the client has gone (ssl errors are OSErrors as well)
1062
+ pass
1063
+ except Exception:
1064
+ self.logger.exception("event stream failed")
1065
+
1066
+ def _logs(self, request: Request) -> Response:
1067
+ def number(name: str, default: int) -> int:
1068
+ try:
1069
+ return int(request.query_value(name, str(default)))
1070
+ except ValueError:
1071
+ raise BadRequest(f"'{name}' must be a number.") from None
1072
+
1073
+ text = request.query.get("text", [None])[-1]
1074
+ return Response(
1075
+ data=self.log_buffer.get(
1076
+ after=number("after", 0),
1077
+ level=request.query_value("level", "DEBUG").upper(),
1078
+ text=text,
1079
+ limit=max(1, min(number("limit", 500), 5000)),
1080
+ )
1081
+ )
1082
+
1083
+ def _add_album(self, request: Request) -> Response:
1084
+ data = request.json()
1085
+ return Response(201, self.control.add_album(data.get("folder"), data.get("title")))
1086
+
1087
+ def _cover(self, request: Request) -> Response:
1088
+ data, mime = self.control.get_cover(request.params["id"])
1089
+ return Response(body=data, content_type=mime, headers={"Cache-Control": "private, max-age=300"})
1090
+
1091
+ def _set_cover(self, request: Request) -> Response:
1092
+ length = request.content_length()
1093
+ if length > MAX_IMAGE_BODY:
1094
+ raise PayloadTooLarge("image is too large.", code="image.too_large")
1095
+ return Response(data=self.control.set_cover(request.params["id"], request.stream.read(length)))
1096
+
1097
+ def _upload_file(self, request: Request) -> Response:
1098
+ length = request.content_length()
1099
+ info = self.uploads.add_file(request.params["id"], request.params["filename"], request.stream, length)
1100
+ return Response(201, info)
1101
+
1102
+ def _finish_upload(self, request: Request) -> Response:
1103
+ data = request.json()
1104
+ album = self.uploads.finish(request.params["id"], data.get("order"))
1105
+ result = {"album": self.control.get_album(album.target_id), "tag": None}
1106
+ tag_uid = data.get("tag_uid")
1107
+ if tag_uid:
1108
+ result["tag"] = self.control.link_tag_album(str(tag_uid), album.target_id)
1109
+ return Response(201, result)
1110
+
1111
+ # --- request handling --------------------------------------------------------------------------------------
1112
+
1113
+ def _token(self, handler: RequestHandler) -> tuple[str | None, bool]:
1114
+ """(token, from cookie)"""
1115
+ authorization = handler.headers.get("Authorization", "")
1116
+ if authorization.lower().startswith("bearer "):
1117
+ return authorization[7:].strip(), False
1118
+ for part in handler.headers.get("Cookie", "").split(";"):
1119
+ name, _, value = part.strip().partition("=")
1120
+ if name == self._cookie and value:
1121
+ return value, True
1122
+ return None, False
1123
+
1124
+ @staticmethod
1125
+ def _idle(handler: RequestHandler, from_cookie: bool) -> float | None:
1126
+ """What a request says about when the user did something last (:meth:`Sessions.get`). The page tells with
1127
+ every request of its own; a request with the cookie that does not tell - an image the browser loads - says
1128
+ nothing. A request with a token in the header comes from a program: it is an action itself."""
1129
+ if not from_cookie:
1130
+ return 0
1131
+ try:
1132
+ idle = float(handler.headers.get(IDLE_HEADER, ""))
1133
+ except ValueError:
1134
+ return None
1135
+ return max(idle, 0.0) if math.isfinite(idle) else None
1136
+
1137
+ def _current_session(self, handler: RequestHandler) -> tuple[Session | None, str | None, bool]:
1138
+ """(session, token, from cookie) of the request. Role changes, removed users and new passwords - also those
1139
+ set with tinyjukebox-users - apply at once: a session whose user is gone or has a new password ends."""
1140
+ token, from_cookie = self._token(handler)
1141
+ session = self.sessions.get(token, self._idle(handler, from_cookie))
1142
+ if session is not None and token is not None:
1143
+ role = self.users.session_role(session)
1144
+ if role is None:
1145
+ self.sessions.remove(token)
1146
+ return None, token, from_cookie
1147
+ session.role = role
1148
+ return session, token, from_cookie
1149
+
1150
+ def handle(self, handler: RequestHandler, method: str, path: str) -> Response:
1151
+ allowed_methods = []
1152
+ guard = next((guard for prefix, guard in self._guards.items() if path.startswith(prefix)), None)
1153
+ for route in self._routes:
1154
+ match = route.regex.match(path)
1155
+ if match is None:
1156
+ continue
1157
+ if route.method != method:
1158
+ allowed_methods.append(route.method)
1159
+ continue
1160
+ params = {k: urllib.parse.unquote(v) for k, v in match.groupdict().items()}
1161
+ if guard is not None:
1162
+ guarded = Request(handler, params, None, None)
1163
+ if route.role is not None:
1164
+ guard(guarded)
1165
+ return route.handler(guarded)
1166
+ session, token, from_cookie = self._current_session(handler)
1167
+ if route.role is not None:
1168
+ if session is None:
1169
+ raise Unauthorized("login required.")
1170
+ if not has_role(session.role, route.role):
1171
+ raise Forbidden(f"role '{route.role}' required.")
1172
+ # a changing request in the name of a user of the page - also one of a route without a role, which may
1173
+ # ask who is logged in (logout, a check for a release): a page of another site cannot set the header
1174
+ if (
1175
+ session is not None
1176
+ and from_cookie
1177
+ and method != "GET"
1178
+ and path != "/api/login"
1179
+ and handler.headers.get(CSRF_HEADER) is None
1180
+ ):
1181
+ raise Forbidden(f"header '{CSRF_HEADER}' missing.")
1182
+ # what the handler changes is noted as changed by this user (tinyjukebox.sync)
1183
+ with acting_as(f"web:{session.username}" if session is not None else "web"):
1184
+ return route.handler(Request(handler, params, session, token))
1185
+ if guard is not None:
1186
+ guard(Request(handler, {}, None, None)) # who may not ask does not learn which paths exist
1187
+ if allowed_methods:
1188
+ raise MethodNotAllowed(allowed_methods)
1189
+ raise NotFound("not found.")
1190
+
1191
+ # --- start / stop ------------------------------------------------------------------------------------------
1192
+
1193
+ def start(self) -> None:
1194
+ self.fingerprint = ensure_certificate(
1195
+ self._config["certificate"], self._config["private-key"], self.logger, self._config["hostnames"]
1196
+ )
1197
+ context = create_ssl_context(self._config["certificate"], self._config["private-key"])
1198
+ self.uploads.remove_stale_uploads()
1199
+ if self._own_log_buffer:
1200
+ self._root_logger.addHandler(self.log_buffer)
1201
+ self._stopping.clear()
1202
+ handler = type("Handler", (RequestHandler,), {"api": self})
1203
+ self._server = _Server((self._config["host"], self._config["port"]), handler, context)
1204
+ self._thread = threading.Thread(target=self._server.serve_forever, name="RestApi", daemon=True)
1205
+ self._thread.start()
1206
+ self.logger.info(
1207
+ "rest api listening on https://%s:%s - certificate fingerprint (sha256): %s",
1208
+ self._config["host"],
1209
+ self.port,
1210
+ self.fingerprint,
1211
+ )
1212
+
1213
+ @property
1214
+ def running(self) -> bool:
1215
+ return self._thread is not None and self._thread.is_alive()
1216
+
1217
+ @property
1218
+ def certificate_file(self) -> str:
1219
+ """The certificate of the page (pem)."""
1220
+ return str(self._config["certificate"])
1221
+
1222
+ @property
1223
+ def port(self) -> int:
1224
+ return int(self._server.server_address[1]) if self._server is not None else int(self._config["port"])
1225
+
1226
+ def stop(self) -> None:
1227
+ if self._server is None:
1228
+ return
1229
+ self._stopping.set()
1230
+ self._server.shutdown()
1231
+ self._server.server_close()
1232
+ if self._thread is not None:
1233
+ self._thread.join()
1234
+ self._server = None
1235
+ self.logger.info("rest api stopped.")
1236
+ if self._own_log_buffer:
1237
+ self._root_logger.removeHandler(self.log_buffer)