tinyjukebox 0.47.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
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])
|