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
tinyjukebox/control.py ADDED
@@ -0,0 +1,1386 @@
1
+ """Everything that can be done with the box - used by the rest api and the command line; the counterpart of the
2
+ hardware buttons and the rfid reader."""
3
+
4
+ import contextlib
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import secrets
11
+ import shutil
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable, Mapping, Sequence
15
+ from typing import Any, Protocol
16
+
17
+ from tinyjukebox import __version__
18
+ from tinyjukebox.backup import JOURNAL, Backup, Folder
19
+ from tinyjukebox.backups import Backups
20
+ from tinyjukebox.core.logging import get_child
21
+ from tinyjukebox.core.pubsub import PubSubClient
22
+ from tinyjukebox.data import degraded, targetalbumtools
23
+ from tinyjukebox.data.activities import Activities
24
+ from tinyjukebox.data.common import TargetType, is_unmapped
25
+ from tinyjukebox.data.sanitycheck import SanityCheck
26
+ from tinyjukebox.data.tag import Tag
27
+ from tinyjukebox.data.tags import Tags
28
+ from tinyjukebox.data.target import (
29
+ TargetAlbum,
30
+ TargetRadio,
31
+ TargetSpotify,
32
+ check_album_title,
33
+ check_spotify_link,
34
+ check_station_name,
35
+ check_stream_url,
36
+ is_mp3_filename,
37
+ read_playlist,
38
+ spotify_kind,
39
+ spotify_link,
40
+ )
41
+ from tinyjukebox.data.targets import Targets
42
+ from tinyjukebox.devices.bluetooth.virtual import VirtualHeadset
43
+ from tinyjukebox.errors import ApiError, BadRequest, Conflict, NotFound, PayloadTooLarge
44
+ from tinyjukebox.headset import Headset
45
+ from tinyjukebox.images import ALBUM_PREFIX, SPOTIFY_PREFIX, Images
46
+ from tinyjukebox.library.statistics import statistics
47
+ from tinyjukebox.messages import BUTTON_HELD, BUTTON_PRESSED, BUTTON_RELEASED, RFID_STATE, Button, RfidState
48
+ from tinyjukebox.metadata import TrackCache, find_cover, read_track, stored_cover
49
+ from tinyjukebox.restore import Restore
50
+ from tinyjukebox.rgbled import RGBLed
51
+ from tinyjukebox.slots import COMMANDS, Slots
52
+ from tinyjukebox.spotify.account import SpotifyAccount, Unavailable
53
+ from tinyjukebox.spotify.oembed import Found, look_up
54
+ from tinyjukebox.stm.stmoperation import AlbumControl
55
+ from tinyjukebox.sync.box import BoxSync
56
+ from tinyjukebox.sync.client import HttpError, Unreachable, WrongCertificate
57
+ from tinyjukebox.sync.data import JOURNAL_LIMIT
58
+ from tinyjukebox.volumemanager import VolumeProfile, Volumes, check_limits
59
+
60
+ __all__ = ["BUTTONS", "BUTTON_ACTIONS", "BoxControl", "CatalogControl", "is_valid_uid"]
61
+
62
+ # button name -> button; action -> topic - the same messages the input drivers (and the bluetooth driver) publish
63
+ BUTTONS = {button.value: button for button in Button}
64
+ BUTTON_ACTIONS = {"press": BUTTON_PRESSED, "hold": BUTTON_HELD, "release": BUTTON_RELEASED}
65
+ _VALID_UID = re.compile(r"^[\w:.\-]{1,64}$")
66
+
67
+
68
+ def is_valid_uid(uid: object) -> bool:
69
+ """uid of a real or virtual tag: letters, digits, ':', '.', '-', '_' ('current' is reserved)."""
70
+ return isinstance(uid, str) and bool(_VALID_UID.match(uid)) and uid != "current"
71
+
72
+
73
+ class Operation(Protocol):
74
+ """What the box control needs of :class:`~tinyjukebox.stm.stmoperation.STMOperation`."""
75
+
76
+ def get_current_uid(self) -> str | None: ...
77
+
78
+ def get_album(self) -> AlbumControl | None: ...
79
+
80
+ def get_active_state(self) -> Any: ...
81
+
82
+ def recheck_tag(self) -> None: ...
83
+
84
+ def test_radio(self, station: TargetRadio | TargetSpotify) -> None: ...
85
+
86
+ def stop_radio_test(self) -> None: ...
87
+
88
+
89
+ def _noop() -> None:
90
+ pass
91
+
92
+
93
+ def hue(seed: str) -> int:
94
+ """The hue (0-359) of the colors that belong to ``seed`` - the same on every start, in every browser. Upper and
95
+ lower case and spaces around it do not count: a name that is only written differently keeps its colors."""
96
+ return int.from_bytes(hashlib.sha256(seed.strip().casefold().encode()).digest()[:4], "big") % 360
97
+
98
+
99
+ class _CollectWarnings(logging.Handler):
100
+ def __init__(self) -> None:
101
+ super().__init__(logging.WARNING)
102
+ self.messages: list[str] = []
103
+
104
+ def emit(self, record: logging.LogRecord) -> None:
105
+ self.messages.append(record.getMessage())
106
+
107
+
108
+ _UNAVAILABLE: dict[str, Callable[[str], Conflict]] = {
109
+ "no_key": lambda message: Conflict(message, code="spotify.no_key"),
110
+ "no_player": lambda message: Conflict(message, code="spotify.no_player"),
111
+ "cannot_run": lambda message: Conflict(message, code="spotify.cannot_run"),
112
+ "expired": lambda message: Conflict(message, code="spotify.expired"),
113
+ }
114
+ """Why Spotify cannot play on this box (:class:`~tinyjukebox.spotify.account.Unavailable`) - as an error of the page."""
115
+
116
+
117
+ class CatalogControl:
118
+ """Operations on tags, albums, radio stations, entries of Spotify and activities - what a box and the library
119
+ (:mod:`tinyjukebox.library`) have in common.
120
+
121
+ Errors are raised as :class:`~tinyjukebox.errors.ApiError` (message for the user). Changes of tags, targets and
122
+ activities are done one at a time (``_lock``); the collections themselves are thread safe as well.
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ config: Mapping[str, Any],
128
+ tags: Tags,
129
+ targets: Targets,
130
+ activities: Activities,
131
+ logger: logging.Logger,
132
+ images: Images | None = None,
133
+ ) -> None:
134
+ self._logger = get_child(logger, type(self).__name__)
135
+ self._root_logger = logger
136
+ self._config = config
137
+ self._tags = tags
138
+ self._targets = targets
139
+ self._activities = activities
140
+ self.images = images
141
+ self.backups: Backups | None = None # the backups that are made here by a schedule (tinyjukebox.backups)
142
+ self.restore: Restore | None = None # putting a backup back (tinyjukebox.restore)
143
+ self._tracks = TrackCache()
144
+ self._lock = threading.RLock()
145
+ self.spotify_lookup: Callable[[str], Found] = lambda uri: look_up(uri, self._logger)
146
+ """Name and cover of an entry of Spotify (tests replace it)."""
147
+
148
+ # --- what a box adds -------------------------------------------------------------------------------------------
149
+
150
+ def _current_uid(self) -> str | None:
151
+ """uid of the tag on the box."""
152
+ return None
153
+
154
+ def _recheck_tag(self) -> None:
155
+ """Evaluate the tag on the box again - what it is linked to changed."""
156
+
157
+ def _is_playing(self, target_id: object) -> bool:
158
+ return False
159
+
160
+ def _album_changed(self, album_id: str) -> None:
161
+ """The files of an album changed (its playlist was written anew, a file was removed)."""
162
+
163
+ def _described(self, album_id: str) -> dict[str, Any]:
164
+ """What the catalog of the library says about the files of an album: ``tracks`` and ``length`` are their
165
+ number and playing time. Empty without a library, or as long as the library does not know."""
166
+ return {}
167
+
168
+ def status(self) -> dict[str, Any]:
169
+ """What the page shows all the time."""
170
+ return {"state": None, "tag": None, "album": None, "radio": None, "spotify": None}
171
+
172
+ def identity(self) -> dict[str, Any]:
173
+ """Who answers - the login page shows it: ``name`` for people (``None``: none) and ``hue``, the hue of the
174
+ colors of the page (``None``: the colors of the project)."""
175
+ return {"name": None, "hue": None}
176
+
177
+ @property
178
+ def playlist_filename(self) -> str:
179
+ return str(self._config["playlist-filename"])
180
+
181
+ # --- helpers -----------------------------------------------------------------------------------------------------
182
+
183
+ def _tag(self, uid: str) -> Tag:
184
+ """Tag by uid - 'current' is the tag on the box."""
185
+ if uid == "current":
186
+ current = self._current_uid()
187
+ if current is None:
188
+ raise NotFound("no tag present.", code="tag.none_present")
189
+ uid = current
190
+ tag = self._tags.tags.get(uid)
191
+ if tag is None:
192
+ raise NotFound(f"unknown tag '{uid}'.", code="tag.unknown", uid=uid)
193
+ return tag
194
+
195
+ def _album(self, album_id: object) -> TargetAlbum:
196
+ # no Targets.get_target - it logs every unknown id and the web page asks for the status every second
197
+ album = self._targets.targets.get(str(album_id))
198
+ if not isinstance(album, TargetAlbum):
199
+ raise NotFound(f"unknown album '{album_id}'.", code="album.unknown", id=album_id)
200
+ return album
201
+
202
+ def _radio(self, radio_id: object) -> TargetRadio:
203
+ radio = self._targets.targets.get(str(radio_id))
204
+ if not isinstance(radio, TargetRadio):
205
+ raise NotFound(f"unknown radio station '{radio_id}'.", code="radio.unknown", id=radio_id)
206
+ return radio
207
+
208
+ def _spotify(self, spotify_id: object) -> TargetSpotify:
209
+ entry = self._targets.targets.get(str(spotify_id))
210
+ if not isinstance(entry, TargetSpotify):
211
+ raise NotFound(f"unknown entry of Spotify '{spotify_id}'.", code="spotify.unknown", id=spotify_id)
212
+ return entry
213
+
214
+ @staticmethod
215
+ def _target_id(tag: Tag, target_type: TargetType) -> str | None:
216
+ """id of the linked target if it is of ``target_type``."""
217
+ return None if is_unmapped(tag.target_id) or tag.target_type != target_type else str(tag.target_id)
218
+
219
+ def _target_exists(self, target_id: str | None, target_type: TargetType) -> bool:
220
+ target = self._targets.targets.get(target_id) if target_id is not None else None
221
+ return target is not None and target.target_type == target_type
222
+
223
+ def _tag_dict(self, tag: Tag) -> dict[str, Any]:
224
+ album_id = self._target_id(tag, TargetType.album)
225
+ radio_id = self._target_id(tag, TargetType.radio)
226
+ spotify_id = self._target_id(tag, TargetType.spotify)
227
+ result: dict[str, Any] = {
228
+ "uid": tag.tag_uid,
229
+ "name": tag.tag_name,
230
+ "album_id": album_id,
231
+ "album_exists": self._target_exists(album_id, TargetType.album),
232
+ "radio_id": radio_id,
233
+ "radio_exists": self._target_exists(radio_id, TargetType.radio),
234
+ "spotify_id": spotify_id,
235
+ "spotify_exists": self._target_exists(spotify_id, TargetType.spotify),
236
+ "created": tag.created,
237
+ "present": tag.tag_uid == self._current_uid(),
238
+ "activity": None,
239
+ }
240
+ activity = self._activities.activities.get(str(tag.tag_uid))
241
+ if activity is not None and album_id is not None and str(activity.target_id) == album_id:
242
+ result["activity"] = {
243
+ "track_id": activity.track_id,
244
+ "track_time": activity.track_time,
245
+ "finished": activity.finished,
246
+ }
247
+ return result
248
+
249
+ def _linked_tags(self, target_id: object, target_type: TargetType = TargetType.album) -> list[dict[str, Any]]:
250
+ return [
251
+ {"uid": t.tag_uid, "name": t.tag_name}
252
+ for t in self._tags.all()
253
+ if t.target_type == target_type and str(t.target_id) == str(target_id)
254
+ ]
255
+
256
+ def _cover(self, target: TargetAlbum | TargetRadio | TargetSpotify) -> str | None:
257
+ """Name of the cover image of the album, station or entry of Spotify; None without a cover, also while the
258
+ image is not here yet."""
259
+ if self.images is None or target.cover is None:
260
+ return None
261
+ return target.cover if self.images.has(target.cover) else None
262
+
263
+ def _radio_dict(self, radio: TargetRadio) -> dict[str, Any]:
264
+ return {
265
+ "id": str(radio.target_id),
266
+ "name": radio.name,
267
+ "url": radio.url,
268
+ "cover": self._cover(radio),
269
+ "created": radio.created,
270
+ "tags": self._linked_tags(radio.target_id, TargetType.radio),
271
+ }
272
+
273
+ def _spotify_dict(self, entry: TargetSpotify) -> dict[str, Any]:
274
+ return {
275
+ "id": str(entry.target_id),
276
+ "name": entry.name,
277
+ "uri": entry.uri,
278
+ "kind": entry.kind,
279
+ "link": spotify_link(entry.uri) if entry.kind else entry.uri,
280
+ "cover": self._cover(entry),
281
+ "created": entry.created,
282
+ "tags": self._linked_tags(entry.target_id, TargetType.spotify),
283
+ }
284
+
285
+ def _album_summary(self, album: TargetAlbum) -> dict[str, Any]:
286
+ tracks = album.get_tracks()
287
+ first = self._tracks.get(tracks[0]) if tracks else {}
288
+ folder = str(album.album_folder)
289
+ return {
290
+ "id": str(album.target_id),
291
+ "folder": folder,
292
+ "playlist": album.playlist,
293
+ "created": album.created,
294
+ "title": album.title or first.get("album") or os.path.basename(folder),
295
+ "artist": first.get("artist"),
296
+ "cover": self._cover(album),
297
+ **self._album_size(album, tracks),
298
+ "tags": self._linked_tags(album.target_id),
299
+ }
300
+
301
+ def _album_size(self, album: TargetAlbum, tracks: list[str]) -> dict[str, Any]:
302
+ """``track_count`` and ``length`` (playing time in seconds, None if unknown) of an album: as the library
303
+ says, which also knows an album that is not on this box, otherwise read from the files that are here."""
304
+ described = self._described(str(album.target_id))
305
+ if "tracks" in described and "length" in described:
306
+ return {"track_count": described["tracks"], "length": described["length"]}
307
+ lengths = [length for length in (self._tracks.get(track)["length"] for track in tracks) if length is not None]
308
+ return {"track_count": len(tracks), "length": round(sum(lengths), 2) if lengths else None}
309
+
310
+ def _album_details(self, album: TargetAlbum) -> dict[str, Any]:
311
+ result = self._album_summary(album)
312
+ tracks = []
313
+ for index, filename in enumerate(album.get_tracks()):
314
+ track = self._tracks.get(filename)
315
+ track["index"] = index
316
+ track["path"] = os.path.relpath(filename, album.full_album_folder)
317
+ tracks.append(track)
318
+ result["tracks"] = tracks
319
+ return result
320
+
321
+ def _inside_albums_folder(self, path: str) -> bool:
322
+ """True if ``path`` (symlinks resolved) is a folder below the albums folder - not the albums folder itself."""
323
+ root = os.path.realpath(self._targets.root_folder)
324
+ folder = os.path.realpath(path)
325
+ return folder != root and os.path.commonpath([root, folder]) == root
326
+
327
+ # --- tags --------------------------------------------------------------------------------------------------------
328
+
329
+ def list_tags(self, unmapped_only: bool = False) -> list[dict[str, Any]]:
330
+ tags = [self._tag_dict(t) for t in self._tags.all()]
331
+ if unmapped_only:
332
+ tags = [t for t in tags if t["album_id"] is None and t["radio_id"] is None and t["spotify_id"] is None]
333
+ return sorted(tags, key=lambda t: ((t["name"] or "").lower(), t["uid"]))
334
+
335
+ def get_tag(self, uid: str) -> dict[str, Any]:
336
+ return self._tag_dict(self._tag(uid))
337
+
338
+ def current_unlinked_tag(self) -> str | None:
339
+ """uid of the tag on the box if it is known and not linked yet."""
340
+ uid = self._current_uid()
341
+ tag = self._tags.tags.get(uid) if uid is not None else None
342
+ return uid if tag is not None and is_unmapped(tag.target_id) else None
343
+
344
+ def create_tag(
345
+ self,
346
+ uid: str | None = None,
347
+ name: str | None = None,
348
+ album_id: str | None = None,
349
+ radio_id: str | None = None,
350
+ spotify_id: str | None = None,
351
+ ) -> dict[str, Any]:
352
+ """Add a tag without placing it on the reader (e.g. for tests without hardware). A random uid in the format
353
+ of the rc522 reader is generated if none is given. The tag is linked to the album, radio station or entry of
354
+ Spotify if given."""
355
+ with self._lock:
356
+ if not uid:
357
+ uid = ":".join(str(secrets.randbelow(256)) for _ in range(4))
358
+ while uid in self._tags.tags:
359
+ uid = ":".join(str(secrets.randbelow(256)) for _ in range(4))
360
+ if not is_valid_uid(uid):
361
+ raise BadRequest("invalid uid (letters, digits, ':', '.', '-', '_').", code="tag.invalid_uid")
362
+ if uid in self._tags.tags or uid in self._tags.unavailable:
363
+ raise Conflict(f"tag '{uid}' exists already.", code="tag.exists", uid=uid)
364
+ if sum(target not in (None, "") for target in (album_id, radio_id, spotify_id)) > 1:
365
+ raise BadRequest("a tag is linked to one album, radio station or entry of Spotify.")
366
+ if album_id not in (None, ""):
367
+ self._album(album_id)
368
+ if radio_id not in (None, ""):
369
+ self._radio(radio_id)
370
+ if spotify_id not in (None, ""):
371
+ self._spotify(spotify_id)
372
+ tag = self._tags.add_tag(uid)
373
+ if name:
374
+ tag.set_tag_name(str(name).strip())
375
+ if album_id not in (None, ""):
376
+ self._tags.assign_target(uid, TargetType.album, str(album_id))
377
+ if radio_id not in (None, ""):
378
+ self._tags.assign_target(uid, TargetType.radio, str(radio_id))
379
+ if spotify_id not in (None, ""):
380
+ self._tags.assign_target(uid, TargetType.spotify, str(spotify_id))
381
+ self._logger.info("created tag '%s'.", uid)
382
+ return self._tag_dict(tag)
383
+
384
+ def set_tag_name(self, uid: str, name: object) -> dict[str, Any]:
385
+ if not isinstance(name, str) or not name.strip():
386
+ raise BadRequest("name must not be empty.", code="name.empty")
387
+ with self._lock:
388
+ tag = self._tag(uid)
389
+ tag.set_tag_name(name.strip())
390
+ return self._tag_dict(tag)
391
+
392
+ def link_tag_album(self, uid: str, album_id: object) -> dict[str, Any]:
393
+ """Link the tag (uid or 'current') with the album. If this tag is on the box, the new album starts right
394
+ away - no need to remove and replace the tag."""
395
+ with self._lock:
396
+ tag = self._tag(uid)
397
+ album = self._album(album_id)
398
+ self._logger.info("link tag '%s' with album '%s'.", tag.tag_uid, album.target_id)
399
+ self._tags.assign_target(str(tag.tag_uid), TargetType.album, str(album.target_id))
400
+ if tag.tag_uid == self._current_uid():
401
+ self._recheck_tag()
402
+ return self._tag_dict(tag)
403
+
404
+ def link_tag_radio(self, uid: str, radio_id: object) -> dict[str, Any]:
405
+ """Link the tag (uid or 'current') with the radio station - it starts right away if the tag is on the box."""
406
+ with self._lock:
407
+ tag = self._tag(uid)
408
+ radio = self._radio(radio_id)
409
+ self._logger.info("link tag '%s' with radio station '%s'.", tag.tag_uid, radio.target_id)
410
+ self._tags.assign_target(str(tag.tag_uid), TargetType.radio, str(radio.target_id))
411
+ if tag.tag_uid == self._current_uid():
412
+ self._recheck_tag()
413
+ return self._tag_dict(tag)
414
+
415
+ def link_tag_spotify(self, uid: str, spotify_id: object) -> dict[str, Any]:
416
+ """Link the tag (uid or 'current') with the entry of Spotify - it starts right away if the tag is on the
417
+ box."""
418
+ with self._lock:
419
+ tag = self._tag(uid)
420
+ entry = self._spotify(spotify_id)
421
+ self._logger.info("link tag '%s' with entry of Spotify '%s'.", tag.tag_uid, entry.target_id)
422
+ self._tags.assign_target(str(tag.tag_uid), TargetType.spotify, str(entry.target_id))
423
+ if tag.tag_uid == self._current_uid():
424
+ self._recheck_tag()
425
+ return self._tag_dict(tag)
426
+
427
+ def unlink_tag(self, uid: str) -> dict[str, Any]:
428
+ with self._lock:
429
+ tag = self._tag(uid)
430
+ tag.set_target_id(-1)
431
+ if tag.tag_uid == self._current_uid():
432
+ self._recheck_tag()
433
+ return self._tag_dict(tag)
434
+
435
+ def remove_tag(self, uid: str) -> None:
436
+ """Remove the tag with its activity."""
437
+ with self._lock:
438
+ tag = self._tag(uid)
439
+ tag_uid = str(tag.tag_uid)
440
+ if tag_uid in self._activities.activities:
441
+ self._activities.delete_activity(tag_uid)
442
+ self._tags.delete_tag(tag_uid)
443
+
444
+ def _images(self) -> Images:
445
+ if self.images is None:
446
+ raise NotFound("no images folder.")
447
+ return self.images
448
+
449
+ # --- albums ------------------------------------------------------------------------------------------------------
450
+
451
+ def list_albums(self, unmapped_only: bool = False) -> list[dict[str, Any]]:
452
+ albums = [self._album_summary(a) for a in self._targets.albums()]
453
+ if unmapped_only:
454
+ albums = [a for a in albums if not a["tags"]]
455
+ return sorted(albums, key=lambda a: (len(a["id"]), a["id"]))
456
+
457
+ def get_album(self, album_id: object) -> dict[str, Any]:
458
+ album = self._album(album_id)
459
+ result = self._album_details(album)
460
+ passed, problems = album.sanity_check()
461
+ prefix = f"{album.full_album_folder}/"
462
+ result["problems"] = (
463
+ [] if passed else [p.replace("sanity_check - ", "").replace(prefix, "") for p in problems.splitlines()]
464
+ )
465
+ return result
466
+
467
+ def album_text(self, album_id: str) -> str:
468
+ """One line description of the album (command line)."""
469
+ return self._album(album_id).to_text()
470
+
471
+ def rebuild_playlist(self, album_id: object) -> dict[str, Any]:
472
+ """Write a new playlist with all mp3 files of the album folder (order: id3 track numbers, otherwise natural
473
+ file name order). The old playlist is kept as <playlist>.bak."""
474
+ with self._lock:
475
+ album = self._album(album_id)
476
+ folder = str(album.full_album_folder)
477
+ try:
478
+ files = sorted(f for f in os.listdir(folder) if is_mp3_filename(f))
479
+ except FileNotFoundError:
480
+ raise NotFound(
481
+ f"album folder '{album.album_folder}' not found.",
482
+ code="album.folder_missing",
483
+ folder=album.album_folder,
484
+ ) from None
485
+ if not files:
486
+ raise BadRequest("album folder contains no mp3 files.", code="album.no_mp3")
487
+ order = targetalbumtools.playlist_order(
488
+ [dict(read_track(os.path.join(folder, f)), filename=f) for f in files]
489
+ )
490
+ playlist = os.path.join(folder, str(album.playlist))
491
+ if os.path.exists(playlist):
492
+ os.replace(playlist, playlist + ".bak")
493
+ targetalbumtools.write_playlist(folder, str(album.playlist), order)
494
+ album.get_tracks(force_update=True)
495
+ self._logger.info("rebuilt playlist of album '%s': %s tracks.", album.target_id, len(order))
496
+ self._album_changed(str(album.target_id))
497
+ return self.get_album(album_id)
498
+
499
+ def get_cover(self, album_id: object) -> tuple[bytes, str]:
500
+ """The cover of the album: its image in the images folder, otherwise an image that came with the album - a
501
+ file of its folder or the picture inside its tracks."""
502
+ album = self._album(album_id)
503
+ name = self._cover(album)
504
+ if name is not None:
505
+ with contextlib.suppress(NotFound, OSError): # removed just now
506
+ return self._images().image(name)
507
+ cover = find_cover(str(album.full_album_folder), album.get_tracks())
508
+ if cover is None:
509
+ raise NotFound(f"album '{album_id}' has no cover.")
510
+ return cover
511
+
512
+ def set_cover(self, album_id: object, data: bytes) -> dict[str, Any]:
513
+ """Make the image ``data`` (jpeg, png or webp) the cover of the album. It is stored in the images folder
514
+ and replaces the cover the album had - also one that an older version stored in the album folder."""
515
+ with self._lock:
516
+ album = self._album(album_id)
517
+ old = album.cover
518
+ album.set_cover(self._images().add(data, ALBUM_PREFIX))
519
+ self._logger.info("album '%s' has a new cover: %s (%s bytes).", album.target_id, album.cover, len(data))
520
+ self._drop_cover(old)
521
+ stored = stored_cover(str(album.full_album_folder))
522
+ if stored is not None:
523
+ os.remove(stored)
524
+ self._album_changed(str(album.target_id))
525
+ return self._album_summary(album)
526
+
527
+ def adopt_stored_covers(self) -> int:
528
+ """Until 0.31 the cover of an album was the file ``cover.jpg`` or ``cover.png`` in its folder, which only a
529
+ backup with the albums holds. Move the stored cover of every album that names no cover into the images
530
+ folder; returns how many were moved. Other images of an album folder and the pictures inside the tracks are
531
+ left alone."""
532
+ if self.images is None:
533
+ return 0
534
+ moved = 0
535
+ with self._lock:
536
+ for album in self._targets.albums():
537
+ stored = stored_cover(str(album.full_album_folder)) if album.cover is None else None
538
+ if stored is None:
539
+ continue
540
+ try:
541
+ with open(stored, "rb") as infile:
542
+ album.set_cover(self.images.add(infile.read(), ALBUM_PREFIX))
543
+ os.remove(stored)
544
+ except (ApiError, OSError) as error:
545
+ self._logger.warning("the cover '%s' stays where it is: %s", stored, error)
546
+ continue
547
+ self._album_changed(str(album.target_id))
548
+ moved += 1
549
+ if moved:
550
+ self._logger.info("%d covers of albums moved from their album folders to '%s'", moved, self.images.folder)
551
+ return moved
552
+
553
+ def update_album(self, album_id: object, title: object) -> dict[str, Any]:
554
+ """Change the title of the album."""
555
+ with self._lock:
556
+ album = self._album(album_id)
557
+ try:
558
+ album.set_title(title)
559
+ except ValueError as e:
560
+ raise BadRequest(str(e)) from None
561
+ return self._album_summary(album)
562
+
563
+ def proposed_title(self, folder: str) -> str:
564
+ """A title for the album in ``folder`` (relative to the albums folder), which is not added yet: the album
565
+ that its first track names, otherwise the name of the folder."""
566
+ path = os.path.join(self._targets.root_folder, folder)
567
+ try:
568
+ entries = read_playlist(os.path.join(path, self.playlist_filename), self.playlist_filename)
569
+ except OSError:
570
+ entries = []
571
+ album = self._tracks.get(os.path.join(path, entries[0])).get("album") if entries else None
572
+ try:
573
+ return check_album_title(album)
574
+ except ValueError:
575
+ return os.path.basename(os.path.normpath(folder))
576
+
577
+ def add_album(self, folder: object, title: object = None) -> dict[str, Any]:
578
+ """Add the album in ``folder`` (relative to the albums folder) with the title ``title``."""
579
+ if not isinstance(folder, str) or not folder.strip():
580
+ raise BadRequest("folder must be provided.", code="album.folder_required")
581
+ try:
582
+ title = check_album_title(title)
583
+ except ValueError:
584
+ raise BadRequest("title must be provided.", code="album.title_required") from None
585
+ folder = os.path.normpath(folder.strip())
586
+ if os.path.isabs(folder) or not self._inside_albums_folder(os.path.join(self._targets.root_folder, folder)):
587
+ raise BadRequest("folder must be relative to the albums folder.", code="album.folder_relative")
588
+ with self._lock:
589
+ if any(a.album_folder == folder for a in self._targets.albums()):
590
+ raise Conflict(f"album folder '{folder}' is already known.", code="album.folder_known", folder=folder)
591
+ try:
592
+ album = targetalbumtools.add_album_to_targets(
593
+ self._targets, self._logger, folder, self.playlist_filename, title
594
+ )
595
+ except ValueError as e:
596
+ raise BadRequest(f"adding album failed: {e}", code="album.add_failed", reason=str(e)) from None
597
+ return self._album_summary(album)
598
+
599
+ def album_folders(self) -> list[str]:
600
+ """All folders with a playlist in the albums folder - known albums and new ones."""
601
+ return targetalbumtools.scan_for_album_folders(self._logger, self._targets.root_folder, self.playlist_filename)
602
+
603
+ def new_album_folders(self) -> list[str]:
604
+ return targetalbumtools.scan_for_new_albums(
605
+ self._targets, self._logger, self._targets.root_folder, self.playlist_filename
606
+ )
607
+
608
+ def new_albums(self) -> list[dict[str, str]]:
609
+ """The folders that are not added as album yet, each with a proposal for its title."""
610
+ return [{"folder": folder, "title": self.proposed_title(folder)} for folder in self.new_album_folders()]
611
+
612
+ def add_new_albums(self, titles: object = None) -> dict[str, list[dict[str, Any]]]:
613
+ """Add all folders that are not added as album yet. ``titles`` maps folders to their titles; a folder that
614
+ it does not name gets the proposed title."""
615
+ if titles is not None and not isinstance(titles, dict):
616
+ raise BadRequest("titles must map folders to titles.")
617
+ added: list[dict[str, Any]] = []
618
+ skipped: list[dict[str, Any]] = []
619
+ with self._lock:
620
+ for folder in self.new_album_folders():
621
+ try:
622
+ title = check_album_title((titles or {}).get(folder) or self.proposed_title(folder))
623
+ album = targetalbumtools.add_album_to_targets(
624
+ self._targets, self._logger, folder, self.playlist_filename, title
625
+ )
626
+ added.append(self._album_summary(album))
627
+ except ValueError as e:
628
+ skipped.append({"folder": folder, "error": str(e)})
629
+ return {"added": added, "skipped": skipped}
630
+
631
+ def delete_album(self, album_id: object, delete_files: bool = False) -> None:
632
+ with self._lock:
633
+ album = self._album(album_id)
634
+ linked = self._linked_tags(album.target_id)
635
+ if linked:
636
+ raise Conflict("album is linked to tags - unlink them first.", tags=linked, code="album.linked")
637
+ if self._is_playing(album.target_id):
638
+ raise Conflict("album is playing.", code="album.playing")
639
+ self._targets.delete_target(str(album.target_id))
640
+ # positions in the deleted album (of tags unlinked before) - nothing can resume them any more
641
+ for uid, activity in self._activities.all().items():
642
+ if str(activity.target_id) == str(album.target_id):
643
+ self._activities.delete_activity(uid)
644
+ self._drop_cover(album.cover)
645
+ folder = str(album.full_album_folder)
646
+ if delete_files and self._inside_albums_folder(folder) and not os.path.islink(folder):
647
+ shutil.rmtree(os.path.realpath(folder), ignore_errors=True)
648
+ self._logger.info("deleted album folder '%s'.", folder)
649
+
650
+ # --- radio stations ----------------------------------------------------------------------------------------------
651
+
652
+ def list_radios(self) -> list[dict[str, Any]]:
653
+ radios = [self._radio_dict(r) for r in self._targets.radios()]
654
+ return sorted(radios, key=lambda r: (r["name"].casefold(), len(r["id"]), r["id"]))
655
+
656
+ def get_radio(self, radio_id: object) -> dict[str, Any]:
657
+ return self._radio_dict(self._radio(radio_id))
658
+
659
+ def add_radio(self, name: object, url: object) -> dict[str, Any]:
660
+ """Add a radio station (``url``: http(s) url of the stream or of an m3u/pls playlist)."""
661
+ with self._lock:
662
+ try:
663
+ radio = self._targets.add_target({"target-type": TargetType.radio.name, "name": name, "url": url})
664
+ except ValueError as e:
665
+ raise BadRequest(str(e), code="radio.invalid") from None
666
+ self._logger.info("added radio station %s.", radio.to_text())
667
+ assert isinstance(radio, TargetRadio) # noqa: S101 - created from a radio dict
668
+ return self._radio_dict(radio)
669
+
670
+ def update_radio(self, radio_id: object, name: object = None, url: object = None) -> dict[str, Any]:
671
+ """Change name and/or url (``None``: unchanged). A station that is playing starts again with the new url."""
672
+ with self._lock:
673
+ radio = self._radio(radio_id)
674
+ old_url = radio.url
675
+ try:
676
+ radio.update(
677
+ None if name is None else check_station_name(name), None if url is None else check_stream_url(url)
678
+ )
679
+ except ValueError as e:
680
+ raise BadRequest(str(e), code="radio.invalid") from None
681
+ if radio.url != old_url and self._is_playing(radio.target_id):
682
+ self._recheck_tag()
683
+ return self._radio_dict(radio)
684
+
685
+ def get_radio_cover(self, radio_id: object) -> tuple[bytes, str]:
686
+ radio = self._radio(radio_id)
687
+ cover = self._cover(radio)
688
+ if cover is None:
689
+ raise NotFound(f"radio station '{radio_id}' has no cover.")
690
+ return self._images().image(cover)
691
+
692
+ def set_radio_cover(self, radio_id: object, data: bytes) -> dict[str, Any]:
693
+ """Make the image ``data`` (jpeg, png or webp) the cover of the station. It is stored in the images folder
694
+ and replaces the cover the station had."""
695
+ with self._lock:
696
+ radio = self._radio(radio_id)
697
+ old = radio.cover
698
+ radio.set_cover(self._images().add(data))
699
+ self._logger.info("radio station '%s' has a new cover: %s.", radio.target_id, radio.cover)
700
+ self._drop_cover(old)
701
+ return self._radio_dict(radio)
702
+
703
+ def _drop_cover(self, cover: str | None) -> None:
704
+ if cover is not None and self.images is not None:
705
+ with contextlib.suppress(NotFound):
706
+ self.images.delete(cover)
707
+
708
+ def delete_radio(self, radio_id: object) -> None:
709
+ with self._lock:
710
+ radio = self._radio(radio_id)
711
+ linked = self._linked_tags(radio.target_id, TargetType.radio)
712
+ if linked:
713
+ raise Conflict("radio station is linked to tags - unlink them first.", tags=linked, code="radio.linked")
714
+ if self._is_playing(radio.target_id):
715
+ raise Conflict("radio station is playing.", code="radio.playing")
716
+ self._targets.delete_target(str(radio.target_id))
717
+ self._drop_cover(radio.cover)
718
+ self._logger.info("deleted radio station %s.", radio.to_text())
719
+
720
+ # --- entries of Spotify ------------------------------------------------------------------------------------------
721
+
722
+ def list_spotify(self) -> list[dict[str, Any]]:
723
+ entries = [self._spotify_dict(e) for e in self._targets.spotify_entries()]
724
+ return sorted(entries, key=lambda e: (e["name"].casefold(), len(e["id"]), e["id"]))
725
+
726
+ def get_spotify(self, spotify_id: object) -> dict[str, Any]:
727
+ return self._spotify_dict(self._spotify(spotify_id))
728
+
729
+ @staticmethod
730
+ def _spotify_uri(link: object) -> str:
731
+ try:
732
+ return check_spotify_link(link)
733
+ except ValueError as e:
734
+ raise BadRequest(str(e), code="spotify.link") from None
735
+
736
+ def look_up_spotify(self, link: object) -> dict[str, Any]:
737
+ """What a link of Spotify is: its address, kind and link, and the name that Spotify gives it (``None`` if
738
+ Spotify says none - e.g. for a podcast, or without internet)."""
739
+ uri = self._spotify_uri(link)
740
+ found = self.spotify_lookup(uri)
741
+ return {"uri": uri, "kind": spotify_kind(uri), "link": spotify_link(uri), "name": found.name}
742
+
743
+ def add_spotify(self, link: object, name: object = None) -> dict[str, Any]:
744
+ """Add an entry of Spotify from its link. Name and cover come from Spotify; ``name`` replaces the name, and
745
+ without one the link is the name."""
746
+ uri = self._spotify_uri(link)
747
+ found = self.spotify_lookup(uri) # not under the lock: it asks Spotify
748
+ if name is None or (isinstance(name, str) and not name.strip()):
749
+ name = found.name or spotify_link(uri)
750
+ with self._lock:
751
+ try:
752
+ entry = self._targets.add_target({"target-type": TargetType.spotify.name, "name": name, "uri": uri})
753
+ except ValueError as e:
754
+ raise BadRequest(str(e), code="spotify.invalid") from None
755
+ assert isinstance(entry, TargetSpotify) # noqa: S101 - created from a spotify dict
756
+ if found.image is not None and self.images is not None:
757
+ try:
758
+ entry.set_cover(self.images.add(found.image, SPOTIFY_PREFIX))
759
+ except (OSError, BadRequest, PayloadTooLarge) as e:
760
+ self._logger.warning("the cover of %s from Spotify is not used: %s", uri, e)
761
+ self._logger.info("added entry of Spotify %s.", entry.to_text())
762
+ return self._spotify_dict(entry)
763
+
764
+ def update_spotify(self, spotify_id: object, name: object = None, link: object = None) -> dict[str, Any]:
765
+ """Change name and/or link (``None``: unchanged). An entry that is playing starts again with the new link."""
766
+ with self._lock:
767
+ entry = self._spotify(spotify_id)
768
+ old_uri = entry.uri
769
+ try:
770
+ entry.update(
771
+ None if name is None else check_station_name(name),
772
+ None if link is None else self._spotify_uri(link),
773
+ )
774
+ except ValueError as e:
775
+ raise BadRequest(str(e), code="spotify.invalid") from None
776
+ if entry.uri != old_uri and self._is_playing(entry.target_id):
777
+ self._recheck_tag()
778
+ return self._spotify_dict(entry)
779
+
780
+ def get_spotify_cover(self, spotify_id: object) -> tuple[bytes, str]:
781
+ entry = self._spotify(spotify_id)
782
+ cover = self._cover(entry)
783
+ if cover is None:
784
+ raise NotFound(f"entry of Spotify '{spotify_id}' has no cover.")
785
+ return self._images().image(cover)
786
+
787
+ def set_spotify_cover(self, spotify_id: object, data: bytes) -> dict[str, Any]:
788
+ """Make the image ``data`` (jpeg, png or webp) the cover of the entry; it replaces the cover it had."""
789
+ with self._lock:
790
+ entry = self._spotify(spotify_id)
791
+ old = entry.cover
792
+ entry.set_cover(self._images().add(data, SPOTIFY_PREFIX))
793
+ self._logger.info("entry of Spotify '%s' has a new cover: %s.", entry.target_id, entry.cover)
794
+ self._drop_cover(old)
795
+ return self._spotify_dict(entry)
796
+
797
+ def delete_spotify(self, spotify_id: object) -> None:
798
+ with self._lock:
799
+ entry = self._spotify(spotify_id)
800
+ linked = self._linked_tags(entry.target_id, TargetType.spotify)
801
+ if linked:
802
+ raise Conflict(
803
+ "entry of Spotify is linked to tags - unlink them first.", tags=linked, code="spotify.linked"
804
+ )
805
+ if self._is_playing(entry.target_id):
806
+ raise Conflict("entry of Spotify is playing.", code="spotify.playing")
807
+ self._targets.delete_target(str(entry.target_id))
808
+ self._drop_cover(entry.cover)
809
+ self._logger.info("deleted entry of Spotify %s.", entry.to_text())
810
+
811
+ # --- activities / maintenance ------------------------------------------------------------------------------------
812
+
813
+ def list_activities(self) -> list[dict[str, Any]]:
814
+ return [
815
+ {
816
+ "tag_uid": uid,
817
+ "album_id": str(a.target_id),
818
+ "track_id": a.track_id,
819
+ "track_time": a.track_time,
820
+ "finished": a.finished,
821
+ }
822
+ for uid, a in sorted(self._activities.all().items())
823
+ ]
824
+
825
+ def rebuild_activities(self) -> list[dict[str, Any]]:
826
+ with self._lock:
827
+ self._activities.rebuild_activities()
828
+ return self.list_activities()
829
+
830
+ def sanity_checks(self) -> dict[str, Any]:
831
+ """Run all sanity checks - returns the result and all warnings."""
832
+ collector = _CollectWarnings()
833
+ self._root_logger.addHandler(collector)
834
+ try:
835
+ passed = SanityCheck(self._logger, self._targets, self._tags, self._activities).sanity_check()
836
+ finally:
837
+ self._root_logger.removeHandler(collector)
838
+ return {"passed": passed, "messages": collector.messages}
839
+
840
+ def backup(self, albums: bool = False) -> Backup:
841
+ """A backup of the data as it is right now, with the audio files of the albums or without
842
+ (:mod:`tinyjukebox.backup`). Nothing secret is part of it."""
843
+ raise NotImplementedError
844
+
845
+
846
+ class BoxControl(CatalogControl):
847
+ """Everything that can be done with the box: the catalog, and status, player, volume, headset, virtual reader
848
+ and virtual buttons."""
849
+
850
+ def __init__(
851
+ self,
852
+ config: Mapping[str, Any],
853
+ pubsub_client: PubSubClient,
854
+ volumes: Volumes,
855
+ stmoperation: Operation,
856
+ tags: Tags,
857
+ targets: Targets,
858
+ activities: Activities,
859
+ logger: logging.Logger,
860
+ rgbled: RGBLed | None = None,
861
+ images: Images | None = None,
862
+ headset: Headset | None = None,
863
+ virtual_headset: VirtualHeadset | None = None,
864
+ sync: BoxSync | None = None,
865
+ slots: Slots | None = None,
866
+ data_files: Sequence[str] = (),
867
+ drivers: Callable[[], list[dict[str, Any]]] | None = None,
868
+ backups: tuple[str, str] | None = None,
869
+ restore: Restore | None = None,
870
+ spotify: SpotifyAccount | None = None,
871
+ ) -> None:
872
+ """``headset``: the bluetooth headset of the box (``None``: no bluetooth); ``virtual_headset``: the headset
873
+ of a box without hardware; ``sync``: the synchronization with the library; ``slots``: which version runs;
874
+ ``data_files``: the json files of the box, for its backup; ``drivers``: the states of the device drivers
875
+ (``None``: the box runs without them); ``backups``: the folder in which the box keeps the backups that it
876
+ makes by itself, and the file of their schedules (``None``: it makes none); ``restore``: puts a backup back;
877
+ ``spotify``: key, player and login of Spotify."""
878
+ super().__init__(config, tags, targets, activities, logger, images)
879
+ self.restore = restore
880
+ if backups is not None:
881
+ # while the box works with a library, the library has the data and makes the backups
882
+ self.backups = Backups(
883
+ *backups, self.backup, logger, kind="box", active=lambda: sync is None or not sync.attached
884
+ )
885
+ self._data_files = list(data_files)
886
+ self._pubsub_client = pubsub_client
887
+ self._volumes = volumes
888
+ self._headset = headset
889
+ self._virtual_headset = virtual_headset
890
+ self._stmoperation = stmoperation
891
+ self._rgbled = rgbled
892
+ self.sync = sync
893
+ self._slots = slots
894
+ self._drivers = drivers
895
+ self.spotify = spotify
896
+
897
+ def _current_uid(self) -> str | None:
898
+ return self._stmoperation.get_current_uid()
899
+
900
+ def _recheck_tag(self) -> None:
901
+ self._stmoperation.recheck_tag()
902
+
903
+ def _album_changed(self, album_id: str) -> None:
904
+ if self.sync is not None:
905
+ self.sync.album_changed(album_id)
906
+
907
+ def _described(self, album_id: str) -> dict[str, Any]:
908
+ if self.sync is None or not self.sync.attached:
909
+ return {}
910
+ return self.sync.data.album(self.sync.data.final(album_id)) or {}
911
+
912
+ # --- status ------------------------------------------------------------------------------------------------------
913
+
914
+ def identity(self) -> dict[str, Any]:
915
+ """The name of the box, if it has one (it gets one with a library), and the hue of its colors: from the name -
916
+ or, while it has none, from the id of the box, which never changes. So every box has colors of its own."""
917
+ if self.sync is None:
918
+ return super().identity()
919
+ name = self.sync.name()
920
+ return {"name": name, "hue": hue(name or self.sync.data.box_id)}
921
+
922
+ def status(self) -> dict[str, Any]:
923
+ uid = self._stmoperation.get_current_uid()
924
+ tag = self._tags.tags.get(uid) if uid is not None else None
925
+ state = self._stmoperation.get_active_state()
926
+ result: dict[str, Any] = {
927
+ "state": getattr(state, "value", state),
928
+ "tag": self._tag_dict(tag) if tag is not None else ({"uid": uid, "name": None} if uid else None),
929
+ "volume": self.get_volume(),
930
+ "led": self._rgbled.get_state() if self._rgbled is not None else None,
931
+ "bluetooth": self.bluetooth_info(),
932
+ "album": None,
933
+ "radio": None,
934
+ "spotify": None,
935
+ }
936
+ if self.sync is not None and self.sync.attached:
937
+ result["sync"] = {
938
+ "connected": self.sync.link.connected,
939
+ "blocked": self.sync.blocked,
940
+ "fetching": self.sync.files.fetching(),
941
+ }
942
+ player = self._stmoperation.get_album()
943
+ kind = player.get_info().get("target_type") if player is not None else None
944
+ if player is not None and kind in ("spotify", "speaker"):
945
+ result["spotify"] = self._spotify_status(player.get_info(), state)
946
+ elif player is not None and kind == "radio":
947
+ info = player.get_info()
948
+ station = self._targets.targets.get(str(info["target_id"])) # none for a test
949
+ result["radio"] = {
950
+ "id": str(info["target_id"]),
951
+ "name": info["name"],
952
+ "url": info["url"],
953
+ "cover": self._cover(station) if isinstance(station, TargetRadio) else None,
954
+ "state": info["state"],
955
+ "error": info["error"],
956
+ "test": getattr(state, "value", state) == "test_radio",
957
+ }
958
+ elif player is not None:
959
+ info = player.get_info()
960
+ try:
961
+ album = self._album_details(self._album(info["target_id"]))
962
+ except NotFound:
963
+ album = {"id": str(info["target_id"]), "folder": info["album_folder"], "tracks": []}
964
+ track = info["track"]
965
+ album["playback"] = {
966
+ "state": info["state"],
967
+ "track_id": track["track_id"],
968
+ "position": track["pos"],
969
+ "length": track["length"],
970
+ }
971
+ result["album"] = album
972
+ return result
973
+
974
+ def _spotify_status(self, info: dict[str, Any], state: object) -> dict[str, Any]:
975
+ """The tab "Now": the entry that plays (or what the phone plays on the box) and its track."""
976
+ if info["target_type"] == "speaker":
977
+ return {"id": None, "name": info["name"], "uri": info["uri"], "kind": None, "cover": None,
978
+ "state": info["state"], "error": None, "track": None, "test": False, "speaker": True} # fmt: skip
979
+ entry = self._targets.targets.get(str(info["target_id"])) # none for a test
980
+ track = info["track"]
981
+ return {
982
+ "id": str(info["target_id"]),
983
+ "name": info["name"],
984
+ "uri": info["uri"],
985
+ "kind": info["kind"],
986
+ "cover": self._cover(entry) if isinstance(entry, TargetSpotify) else None,
987
+ "state": info["state"],
988
+ "error": info["error"],
989
+ "track": {
990
+ "index": track["track_id"],
991
+ "title": track["title"],
992
+ "position": track["pos"],
993
+ "length": track["length"],
994
+ },
995
+ "test": getattr(state, "value", state) == "test_radio",
996
+ "speaker": False,
997
+ }
998
+
999
+ def album_info(self) -> dict[str, Any] | None:
1000
+ """Raw information of the playing album or radio station (see STMPlayAlbum.get_info, PlayRadio.get_info) or
1001
+ None."""
1002
+ player = self._stmoperation.get_album()
1003
+ return player.get_info() if player is not None else None
1004
+
1005
+ def state(self) -> Any:
1006
+ return self._stmoperation.get_active_state()
1007
+
1008
+ # --- library ---------------------------------------------------------------------------------------------------
1009
+
1010
+ def _sync(self) -> BoxSync:
1011
+ if self.sync is None:
1012
+ raise NotFound("this box has no synchronization.")
1013
+ return self.sync
1014
+
1015
+ def library_info(self) -> dict[str, Any]:
1016
+ """The state of the synchronization with the library (docs/developer/library.md)."""
1017
+ return self._sync().info()
1018
+
1019
+ def set_library(self, url: object, bootstrap: object = None) -> dict[str, Any]:
1020
+ """Enter the address of the library (``https://host:port``) and, for the first contact, the bootstrap
1021
+ password. A box that has not worked with a library before does so from its next start."""
1022
+ if not isinstance(url, str) or not url.strip().startswith("https://"):
1023
+ raise BadRequest("the address of the library must start with https://.", code="library.address_https")
1024
+ if bootstrap is not None and not isinstance(bootstrap, str):
1025
+ raise BadRequest("bootstrap must be the bootstrap password.")
1026
+ self._sync().set_library(url.strip().rstrip("/"), bootstrap or None)
1027
+ self._logger.info("library: %s", url.strip())
1028
+ return self.library_info()
1029
+
1030
+ def unlink_library(self) -> dict[str, Any]:
1031
+ """Leave the library: the box keeps what is on its card and works for itself. Changes that the library has
1032
+ not confirmed are not delivered any more, and an album that is not on the card cannot be fetched. The library
1033
+ is not asked and need not be reachable."""
1034
+ if not self._sync().library and not self._sync().attached:
1035
+ raise Conflict("this box is not linked to a library.", code="box.not_linked")
1036
+ self._sync().unlink()
1037
+ self._logger.info("library: unlinked")
1038
+ if self.backups is not None:
1039
+ self.backups.wake() # from now on the box makes its backups itself
1040
+ return self.library_info()
1041
+
1042
+ def set_box_name(self, name: object) -> dict[str, Any]:
1043
+ """The name of this box for people - the pages show it; nothing else depends on it."""
1044
+ if not isinstance(name, str) or not name.strip():
1045
+ raise BadRequest("name must not be empty.", code="name.empty")
1046
+ if not self._sync().attached:
1047
+ raise Conflict("the box has a name as soon as it works with a library.", code="box.name_needs_library")
1048
+ self._sync().set_name(name.strip())
1049
+ return self.library_info()
1050
+
1051
+ def statistics(self) -> dict[str, Any]:
1052
+ """What was played on this box (docs/developer/library.md, section 7). The library keeps the journal of a
1053
+ box and counts it (``origin``: library); a box without a library counts the lines it has kept itself, the
1054
+ last ``limit`` ones (``origin``: box). ``missing`` says why there is nothing to show: the library cannot be
1055
+ reached, or it is older than this box and does not answer this question yet."""
1056
+ sync = self._sync()
1057
+ if sync.attached:
1058
+ try:
1059
+ return {"origin": "library", "missing": None, **sync.link.statistics()}
1060
+ except HttpError as error:
1061
+ return {"origin": "library", "missing": "library_too_old" if error.status == 404 else "unreachable"}
1062
+ except (Unreachable, WrongCertificate):
1063
+ return {"origin": "library", "missing": "unreachable"}
1064
+ box = sync.data.box_id
1065
+ lines = [{**line, "box": box, "discarded": False} for line in sync.data.journal_lines(None)]
1066
+ with self._lock:
1067
+ tags = {str(tag.tag_uid): {"tag": {"name": tag.tag_name}} for tag in self._tags.all()}
1068
+ targets: dict[str, Any] = {
1069
+ str(album.target_id): {"title": album.title, "album-folder": os.path.basename(str(album.album_folder))}
1070
+ for album in self._targets.albums()
1071
+ }
1072
+ targets.update({str(radio.target_id): {"name": radio.name} for radio in self._targets.radios()})
1073
+ targets.update({str(entry.target_id): {"name": entry.name} for entry in self._targets.spotify_entries()})
1074
+ counted = statistics(lines, [], tags, targets, {box: box})
1075
+ return {"origin": "box", "missing": None, "limit": JOURNAL_LIMIT, "box": box, **counted}
1076
+
1077
+ # --- Spotify: account and player ----------------------------------------------------------------------------
1078
+
1079
+ def _spotify_account(self) -> SpotifyAccount:
1080
+ if self.spotify is None:
1081
+ raise NotFound("this box does not play Spotify.")
1082
+ return self.spotify
1083
+
1084
+ def spotify_account(self) -> dict[str, Any]:
1085
+ """The card "Account": key there or not, connected or not, the switch of the speaker."""
1086
+ return self._spotify_account().info()
1087
+
1088
+ def set_spotify_key(self, key: object) -> dict[str, Any]:
1089
+ self._spotify_account().set_key(key)
1090
+ return self.spotify_account()
1091
+
1092
+ def set_spotify_speaker(self, enabled: object) -> dict[str, Any]:
1093
+ self._spotify_account().set_speaker(enabled)
1094
+ return self.spotify_account()
1095
+
1096
+ def connect_spotify(self) -> dict[str, Any]:
1097
+ """The box waits a few minutes to be chosen in the Spotify app, which connects it to its account."""
1098
+ account = self._spotify_account()
1099
+ try:
1100
+ account.connect()
1101
+ except Unavailable as e:
1102
+ raise _UNAVAILABLE[e.reason](str(e)) from None
1103
+ return self.spotify_account()
1104
+
1105
+ def disconnect_spotify(self) -> dict[str, Any]:
1106
+ self._spotify_account().disconnect()
1107
+ return self.spotify_account()
1108
+
1109
+ def spotify_player(self) -> dict[str, Any]:
1110
+ """The card "Player": state, age and last day of the build of Soloist, the last question to Spotify."""
1111
+ return self._spotify_account().player_info()
1112
+
1113
+ def update_spotify_player(self) -> dict[str, Any]:
1114
+ """Ask Spotify at once for a new build - fetched in the background."""
1115
+ self._spotify_account().update_now()
1116
+ return self.spotify_player()
1117
+
1118
+ # --- versions of the box software ----------------------------------------------------------------------------
1119
+
1120
+ def backup(self, albums: bool = False) -> Backup:
1121
+ """The json files, the covers of albums and stations and, with ``albums``, the albums folder. Not what the box
1122
+ exchanges with the library and not its identity: a box that is set up again is a new box for the library.
1123
+
1124
+ A box without a library counts its statistics from the lines of its journal, which nobody else has: they are
1125
+ part of its backup. With a library the library has them, and its backup."""
1126
+ data = []
1127
+ with self._lock:
1128
+ for filename in self._data_files:
1129
+ # a file that is being written is missing for a moment: its backup copy is the complete one before
1130
+ for candidate in (filename, filename + "_"):
1131
+ with contextlib.suppress(FileNotFoundError), open(candidate, "rb") as infile:
1132
+ data.append((f"json/{os.path.basename(filename)}", infile.read()))
1133
+ break
1134
+ if self.sync is not None and not self.sync.attached:
1135
+ lines = self.sync.data.journal_lines(None)
1136
+ data.append((JOURNAL, "".join(json.dumps(line) + "\n" for line in lines).encode("utf-8")))
1137
+ folders = [Folder("images", self.images.folder)] if self.images is not None else []
1138
+ if albums:
1139
+ folders.append(Folder("albums", self._targets.root_folder))
1140
+ return Backup("box", albums, data, folders)
1141
+
1142
+ def system_info(self) -> dict[str, Any]:
1143
+ """Running version, slots, update mode, states of the versions and the last switches
1144
+ (docs/developer/library.md, section 6). ``available``: the box was set up with slots."""
1145
+ available = self._slots is not None and self._slots.available
1146
+ status = self._slots.status() if self._slots is not None and available else {}
1147
+ library = self.sync.library if self.sync is not None else ""
1148
+ # what this version could not load and works without (library.md, section 4.21)
1149
+ return {
1150
+ "available": available,
1151
+ "version": __version__,
1152
+ "library": library,
1153
+ "degraded": degraded.notes(),
1154
+ **status,
1155
+ }
1156
+
1157
+ def hardware_info(self) -> dict[str, Any]:
1158
+ """What the drivers say about the buttons, the led and the rfid reader right now - the technical view.
1159
+ ``available``: the box runs with device drivers; ``time``: the clock of the box, to which the times of the
1160
+ states refer."""
1161
+ states = self._drivers() if self._drivers is not None else []
1162
+
1163
+ def of(driver_type: str) -> list[dict[str, Any]]:
1164
+ return [state for state in states if state["type"] == driver_type]
1165
+
1166
+ return {
1167
+ "available": self._drivers is not None,
1168
+ "time": time.time(),
1169
+ "buttons": of("input"),
1170
+ "leds": of("rgbled"),
1171
+ "readers": of("rfid"),
1172
+ }
1173
+
1174
+ def system_command(self, command: object, arguments: object, by: str) -> dict[str, Any]:
1175
+ """``check``, ``update``, ``revert``, ``use``, ``fallback``, ``promote``, ``mode`` or ``skip`` of
1176
+ ``tinyjukebox-slot`` with its arguments, e.g. ``update --now`` - returns its exit status and what it printed."""
1177
+ if self._slots is None or not self._slots.available:
1178
+ raise Conflict("this box was not set up with slots - its version is changed with the playbook.")
1179
+ if command not in COMMANDS:
1180
+ raise NotFound(f"unknown command '{command}' - valid: {', '.join(COMMANDS)}.")
1181
+ if not isinstance(arguments, list) or not all(isinstance(argument, str) for argument in arguments):
1182
+ raise BadRequest("arguments must be a list of texts.")
1183
+ if any(argument in ("--root", "--by") for argument in arguments):
1184
+ raise BadRequest("--root and --by are not for the web page.")
1185
+ self._logger.info("tinyjukebox-slot %s %s (%s)", command, " ".join(arguments), by)
1186
+ result = self._slots.command(str(command), ["--yes", *arguments], by)
1187
+ if self.sync is not None:
1188
+ self.sync.tell() # the library learns at once what changed, e.g. the update mode
1189
+ return result
1190
+
1191
+ # --- virtual rfid reader -----------------------------------------------------------------------------------------
1192
+
1193
+ def _publish_rfid(self, uid: str | None) -> None:
1194
+ self._pubsub_client.publish(RFID_STATE, RfidState(uid))
1195
+
1196
+ def place_tag(self, uid: object) -> None:
1197
+ """Publish the message of the rfid driver - the box reacts as if the tag had been placed. Unknown uids are
1198
+ added as unmapped tags by the box - exactly like new real tags."""
1199
+ if not is_valid_uid(uid):
1200
+ raise BadRequest("invalid uid.")
1201
+ self._logger.info("virtual tag '%s' placed.", uid)
1202
+ self._publish_rfid(str(uid))
1203
+
1204
+ def remove_placed_tag(self) -> None:
1205
+ self._logger.info("virtual tag removed.")
1206
+ self._publish_rfid(None)
1207
+
1208
+ # --- radio test --------------------------------------------------------------------------------------------------
1209
+
1210
+ def test_radio(self, url: object, name: object = None) -> dict[str, Any]:
1211
+ """Play ``url`` (a stream or m3u/pls playlist - saved as station or not) for a test. Only while no tag is on
1212
+ the box - a tag placed during the test ends it; the test also ends by itself after a minute."""
1213
+ try:
1214
+ url = check_stream_url(url)
1215
+ name = url if name in (None, "") else check_station_name(name)
1216
+ except ValueError as e:
1217
+ raise BadRequest(str(e), code="radio.invalid") from None
1218
+ station = TargetRadio(
1219
+ {"target-id": "test", "target-type": TargetType.radio.name, "name": name, "url": url}, self._logger, _noop
1220
+ )
1221
+ try:
1222
+ self._stmoperation.test_radio(station)
1223
+ except RuntimeError as e:
1224
+ raise Conflict(f"radio test not possible: {e}.", code="radio.test_impossible") from None
1225
+ self._logger.info("radio test of '%s' (%s).", name, url)
1226
+ return {"name": name, "url": url}
1227
+
1228
+ def stop_radio_test(self) -> None:
1229
+ if getattr(self._stmoperation.get_active_state(), "value", None) != "test_radio":
1230
+ raise Conflict("no radio test running.")
1231
+ self._stmoperation.stop_radio_test()
1232
+
1233
+ def test_spotify(self, spotify_id: object = None, link: object = None) -> dict[str, Any]:
1234
+ """Play an entry of Spotify - saved (``spotify_id``) or not (``link``) - for a minute, from its beginning.
1235
+ Only while no tag is on the box, as the test of a station."""
1236
+ if spotify_id not in (None, ""):
1237
+ saved = self._spotify(spotify_id)
1238
+ name, uri = saved.name, saved.uri
1239
+ else:
1240
+ uri = self._spotify_uri(link)
1241
+ name = spotify_link(uri)
1242
+ entry = TargetSpotify(
1243
+ {"target-id": "test", "target-type": TargetType.spotify.name, "name": name, "uri": uri}, self._logger, _noop
1244
+ )
1245
+ try:
1246
+ self._stmoperation.test_radio(entry)
1247
+ except RuntimeError as e:
1248
+ raise Conflict(f"test not possible: {e}.", code="radio.test_impossible") from None
1249
+ self._logger.info("test of the entry of Spotify '%s' (%s).", name, uri)
1250
+ return {"name": name, "uri": uri}
1251
+
1252
+ def _is_playing(self, target_id: object) -> bool:
1253
+ player = self._stmoperation.get_album()
1254
+ return player is not None and str(player.get_info().get("target_id")) == str(target_id)
1255
+
1256
+ # --- player / volume / buttons -----------------------------------------------------------------------------------
1257
+
1258
+ def _player(self) -> AlbumControl:
1259
+ player = self._stmoperation.get_album()
1260
+ if player is None:
1261
+ raise Conflict(
1262
+ "no album is playing.", code="player.no_album"
1263
+ ) # a radio station only ends a pause with next/prev
1264
+ return player
1265
+
1266
+ def track_next(self) -> None:
1267
+ self._player().cmd_next_handler()
1268
+
1269
+ def track_prev(self) -> None:
1270
+ self._player().cmd_prev_handler()
1271
+
1272
+ def restart_album(self) -> None:
1273
+ self._player().cmd_restart_album()
1274
+
1275
+ def pause(self) -> None:
1276
+ """Pause the album (the position is kept) or the radio station - the tag stays on the box."""
1277
+ self._player().cmd_pause()
1278
+
1279
+ def play(self) -> None:
1280
+ """End the pause."""
1281
+ self._player().cmd_play()
1282
+
1283
+ def get_volume(self) -> dict[str, Any]:
1284
+ """The active volume (``output``: what the buttons change) and both profiles with start value and maximum."""
1285
+ active = self._volumes.manager()
1286
+ profiles = {}
1287
+ for profile in VolumeProfile:
1288
+ manager = self._volumes.manager(profile)
1289
+ profiles[profile.value] = {
1290
+ "volume": manager.get_volume(),
1291
+ "default_volume": manager.default_volume,
1292
+ "max_volume": manager.max_volume,
1293
+ }
1294
+ return {
1295
+ "volume": active.get_volume(),
1296
+ "max_volume": active.max_volume,
1297
+ "default_volume": active.default_volume,
1298
+ "step": active.volume_step,
1299
+ "output": self._volumes.active.value,
1300
+ "profiles": profiles,
1301
+ }
1302
+
1303
+ def set_volume(self, volume: object) -> dict[str, Any]:
1304
+ if isinstance(volume, bool) or not isinstance(volume, (int, float)) or not 0 <= volume <= 100:
1305
+ raise BadRequest("volume must be a number between 0 and 100.")
1306
+ self._volumes.set_volume(volume)
1307
+ return self.get_volume()
1308
+
1309
+ def set_volume_limits(self, profile: object, default_volume: object, max_volume: object) -> dict[str, Any]:
1310
+ """Change start value and maximum of ``profile`` (``speaker``/``headphones``) - kept over restarts. A
1311
+ current volume above the new maximum is lowered at once."""
1312
+ if profile not in tuple(VolumeProfile):
1313
+ raise NotFound(f"unknown volume '{profile}' - valid: {', '.join(VolumeProfile)}.")
1314
+ try:
1315
+ self._volumes.set_limits(VolumeProfile(str(profile)), *check_limits(default_volume, max_volume))
1316
+ except ValueError as e:
1317
+ raise BadRequest(str(e)) from None
1318
+ self._logger.info("volume limits of %s: start %s, maximum %s.", profile, default_volume, max_volume)
1319
+ return self.get_volume()
1320
+
1321
+ # --- bluetooth headset -------------------------------------------------------------------------------------------
1322
+
1323
+ def bluetooth_info(self) -> dict[str, Any]:
1324
+ """The paired headset (``available``: there is a bluetooth driver; ``virtual``: box without hardware) with
1325
+ start value and maximum of its volume."""
1326
+ headset = self._headset
1327
+ info: dict[str, Any] = (
1328
+ headset.info()
1329
+ if headset is not None
1330
+ else {"available": False, "virtual": False, "device": None, "connected": False, "battery": None,
1331
+ "volume": None, "pairing": False, "pairing_result": None}
1332
+ ) # fmt: skip
1333
+ manager = self._volumes.manager(VolumeProfile.HEADPHONES)
1334
+ info["default_volume"] = manager.default_volume
1335
+ info["max_volume"] = manager.max_volume
1336
+ return info
1337
+
1338
+ def _available_headset(self) -> Headset:
1339
+ if self._headset is None or self._headset.state is None:
1340
+ raise Conflict("bluetooth is not available.")
1341
+ return self._headset
1342
+
1343
+ def bluetooth_pair(self) -> dict[str, Any]:
1344
+ """Search a headset in pairing mode (up to a minute) and pair it - it replaces the paired one."""
1345
+ headset = self._available_headset()
1346
+ if headset.info()["pairing"]:
1347
+ raise Conflict("a search is running already.", code="bluetooth.searching")
1348
+ headset.pair()
1349
+ return self.bluetooth_info()
1350
+
1351
+ def bluetooth_unpair(self) -> None:
1352
+ """Remove the paired headset - a playing album pauses."""
1353
+ headset = self._available_headset()
1354
+ if headset.info()["device"] is None:
1355
+ raise Conflict("no headset is paired.", code="bluetooth.not_paired")
1356
+ headset.unpair()
1357
+
1358
+ def virtual_headset(self, connected: object = None, button: object = None) -> dict[str, Any]:
1359
+ """The virtual headset of a box without hardware: switch it on/off (``connected``) or press one of its
1360
+ buttons."""
1361
+ virtual = self._virtual_headset
1362
+ if virtual is None:
1363
+ raise NotFound("no virtual headset - the box runs with hardware.")
1364
+ if connected is not None:
1365
+ if not isinstance(connected, bool):
1366
+ raise BadRequest("connected must be true or false.")
1367
+ if virtual.state.device is None:
1368
+ raise Conflict("the virtual headset is not paired.")
1369
+ virtual.switch(connected)
1370
+ if button is not None:
1371
+ if button not in BUTTONS:
1372
+ raise BadRequest(f"unknown button '{button}' - valid: {', '.join(BUTTONS)}.")
1373
+ if not virtual.state.connected:
1374
+ raise Conflict("the virtual headset is not connected.")
1375
+ virtual.press(BUTTONS[str(button)])
1376
+ return self.bluetooth_info()
1377
+
1378
+ def press_button(self, button: str, action: object = "press") -> None:
1379
+ """Publish the message of the hardware button - the box reacts exactly as if it had been pressed."""
1380
+ if button not in BUTTONS:
1381
+ raise NotFound(f"unknown button '{button}' - valid: {', '.join(BUTTONS)}.")
1382
+ if not isinstance(action, str) or action not in BUTTON_ACTIONS:
1383
+ raise BadRequest(f"unknown action '{action}' - valid: {', '.join(BUTTON_ACTIONS)}.")
1384
+ topic = BUTTON_ACTIONS[action]
1385
+ self._logger.info("button %s (%s) -> %s", button, action, topic)
1386
+ self._pubsub_client.publish(topic, BUTTONS[button])