tinyjukebox 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,680 @@
1
+ """The Tiny Jukebox service: drivers, state machines, player, volume, command line and rest api in one process."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import functools
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import signal
10
+ import socket
11
+ import subprocess
12
+ import threading
13
+ import traceback
14
+ from collections.abc import Callable, Mapping, Sequence
15
+ from typing import Any
16
+
17
+ from tinyjukebox import __version__, data_folder
18
+ from tinyjukebox.albumplayer.radioplayer import RadioPlayer
19
+ from tinyjukebox.albumplayer.simpleplayer import AudioOutput, SimplePlayer
20
+ from tinyjukebox.albumplayer.spotifyplayer import SpotifyPlayer
21
+ from tinyjukebox.cli import CLI
22
+ from tinyjukebox.control import BoxControl
23
+ from tinyjukebox.core import config as core_config
24
+ from tinyjukebox.core.logging import get_child
25
+ from tinyjukebox.core.microservice import AbstractMicroservice
26
+ from tinyjukebox.core.pubsub import LocalQueue, PubSubClient
27
+ from tinyjukebox.data.activities import Activities
28
+ from tinyjukebox.data.activity import ActivityAlbum
29
+ from tinyjukebox.data.tags import Tags
30
+ from tinyjukebox.data.target import TargetAlbum, TargetRadio, TargetSpotify
31
+ from tinyjukebox.data.targets import LAST_ID_SUFFIX, Targets
32
+ from tinyjukebox.data.volumelimits import VolumeLimits
33
+ from tinyjukebox.devices.bluetooth.virtual import VirtualHeadset
34
+ from tinyjukebox.devices.drivermanager.drivermanager import DriverManager
35
+ from tinyjukebox.devices.schema.drivermanager import get_schema as get_drivers_schema
36
+ from tinyjukebox.diagnostics import MAX_TRACEBACK, Collector, redact
37
+ from tinyjukebox.headset import BluetoothVolumeControl, Headset
38
+ from tinyjukebox.images import Images, take_over
39
+ from tinyjukebox.latency import ReactionTimer
40
+ from tinyjukebox.messages import JOURNAL, PLAYBACK, JournalEvent, PlaybackStatus
41
+ from tinyjukebox.restore import RESTART, Place, Restore, missing_albums
42
+ from tinyjukebox.rgbled import ALARM, Color, RGBLed
43
+ from tinyjukebox.schema.tinyjukebox import get_schema
44
+ from tinyjukebox.slots import LOOK, Health, Slots
45
+ from tinyjukebox.spotify.account import SpotifyAccount, Unavailable
46
+ from tinyjukebox.spotify.engine import SpotifyEngine
47
+ from tinyjukebox.stm.playradio import PlayRadio, RadioPlayerFactory
48
+ from tinyjukebox.stm.playspotify import PlaySpotify
49
+ from tinyjukebox.stm.stmoperation import FORCED_POLLING_INTERVAL, STMOperation
50
+ from tinyjukebox.stm.stmplayalbum import PlayerFactory, STMPlayAlbum
51
+ from tinyjukebox.stm.stmvolume import STMVolume
52
+ from tinyjukebox.sync.box import BoxSync
53
+ from tinyjukebox.sync.util import read_json
54
+ from tinyjukebox.volumemanager import (
55
+ AlsaVolumeControl,
56
+ VirtualVolumeControl,
57
+ VolumeControl,
58
+ VolumeManager,
59
+ VolumeProfile,
60
+ Volumes,
61
+ )
62
+ from tinyjukebox.web.logbuffer import LogBuffer
63
+ from tinyjukebox.web.server import RestApi
64
+
65
+ __all__ = ["HEADPHONES_DEFAULTS", "TinyJukebox", "standalone"]
66
+
67
+ HEADPHONES_DEFAULTS = {"default-volume": 40, "max-volume": 75}
68
+ """Volume of the headset (hearing protection) unless the config (``bluetooth``) or the volume json file says
69
+ otherwise."""
70
+
71
+
72
+ def _stop_on_signal(signum: int, frame: object) -> None:
73
+ """SIGTERM (systemd) and SIGHUP (screen -X quit, closed terminal) stop the service like Ctrl-C.
74
+
75
+ Further signals are ignored from then on: on a box with slots systemd and the start script may both send one,
76
+ and a stop that is interrupted leaves threads behind that keep the process alive until systemd kills it."""
77
+ for further in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
78
+ signal.signal(further, signal.SIG_IGN)
79
+ raise KeyboardInterrupt(signal.Signals(signum).name)
80
+
81
+
82
+ def _stop_soon() -> None:
83
+ """Stop the program as SIGTERM does, after the answer to the request that asked for it has gone out."""
84
+ threading.Timer(0.5, signal.raise_signal, (signal.SIGTERM,)).start()
85
+
86
+
87
+ class TinyJukebox(AbstractMicroservice):
88
+ """Tiny Jukebox box - see docs/developer/configuration.md for the configuration (section ``tinyjukebox``)."""
89
+
90
+ _version = __version__
91
+
92
+ def __init__(
93
+ self,
94
+ config: Mapping[str, Any],
95
+ pubsub_client: PubSubClient | None = None,
96
+ logger: logging.Logger | None = None,
97
+ stdout_log_level: str | None = None,
98
+ no_gui: bool | None = None,
99
+ hardware: bool = True,
100
+ webserver: bool | None = None,
101
+ audio: bool = True,
102
+ player_factory: PlayerFactory | None = None,
103
+ poll_interval: float = FORCED_POLLING_INTERVAL,
104
+ radio_player_factory: RadioPlayerFactory | None = None,
105
+ ) -> None:
106
+ """
107
+ :param hardware: if False, run without device drivers (section ``drivers``) and without alsa mixer - with a
108
+ virtual bluetooth headset.
109
+ :param webserver: True/False - enable/disable rest api and web page; None - as configured in ``rest-api``.
110
+ :param audio: if False, tracks are decoded in real time but not played (no sound card needed).
111
+ :param player_factory: album player (default: :class:`SimplePlayer`) - for tests.
112
+ :param radio_player_factory: radio player (default: :class:`RadioPlayer`) - for tests.
113
+ :param poll_interval: seconds between the requests for the current rfid state after the start.
114
+ """
115
+ super().__init__(
116
+ config,
117
+ "tinyjukebox",
118
+ pubsub_client=pubsub_client,
119
+ logger=logger,
120
+ logger_name=type(self).__name__,
121
+ stdout_log_level=stdout_log_level,
122
+ no_gui=no_gui,
123
+ )
124
+ self._logger.info("initializing (hardware: %s, webserver: %s, audio: %s)", hardware, webserver, audio)
125
+ self._expand_folders()
126
+ rest_api_config: dict[str, Any] | None = self._config.get("rest-api")
127
+ if webserver is None:
128
+ webserver = rest_api_config is not None and rest_api_config.get("enabled", True)
129
+ # attached right away - the web page and the library get the log of the start as well
130
+ self._log_buffer: LogBuffer | None = LogBuffer() if webserver else None
131
+ self._diagnostics = Collector(root=self._logger.name) # for the library (docs/developer/library.md, 4.22)
132
+ for handler in (self._log_buffer, self._diagnostics):
133
+ if handler is not None:
134
+ self._logger.addHandler(handler)
135
+
136
+ self._tag_latency = ReactionTimer("tag", self._logger)
137
+ # speaker or headset - the players look it up whenever a playback starts
138
+ self._output = AudioOutput(null_output=not audio, buffer_time=self._config["albumplayer"].get("buffer-time"))
139
+ self._player_factory: PlayerFactory = player_factory or functools.partial(
140
+ SimplePlayer,
141
+ on_play=functools.partial(self._tag_latency.end, "ffmpeg started"),
142
+ output=self._output,
143
+ )
144
+ self._radio_player_factory: RadioPlayerFactory = radio_player_factory or functools.partial(
145
+ RadioPlayer,
146
+ on_play=functools.partial(self._tag_latency.end, "ffmpeg started"),
147
+ output=self._output,
148
+ )
149
+
150
+ # the drivers use the pubsub client of the box; it is connected and disconnected by TinyJukebox
151
+ self._driver_manager: DriverManager | None = None
152
+ if hardware and "drivers" in config:
153
+ self._driver_manager = DriverManager(config, self._pubsub_client, self._logger)
154
+ for name, command in self._driver_manager.ui_commands.items():
155
+ self._add_ui_command(name, command)
156
+ elif hardware:
157
+ self._logger.warning("no config section 'drivers' - running without device drivers")
158
+ self._virtual_headset: VirtualHeadset | None = None
159
+ if not hardware:
160
+ self._virtual_headset = VirtualHeadset(self._pubsub_client, self._logger)
161
+ for name, command in self._virtual_headset.ui_commands.items():
162
+ self._add_ui_command(name, command)
163
+
164
+ json_files = self._config["json"]
165
+ images_folder = self._images_folder(rest_api_config or {})
166
+ # with a library the json files are generated from what it delivered and the changes of this box
167
+ self._sync = self._create_sync(images_folder)
168
+ self._tags = Tags(json_files["tags"], self._logger)
169
+ self._targets = Targets(json_files["targets"], self._config["albums-folder"], self._logger)
170
+ self._activities = Activities(json_files["activities"], self._tags, self._logger)
171
+ self._rgb_led = RGBLed(self._logger, self._pubsub_client)
172
+
173
+ self._mixer_watch: AlsaVolumeControl | None = None # follows volume changes made outside tinyjukebox
174
+ headphones_control = BluetoothVolumeControl(self._pubsub_client, self._logger)
175
+ self._limits = VolumeLimits(self._volume_file(), self._logger)
176
+ self._volumes = self._create_volumes(hardware, headphones_control)
177
+ self._stmvolume = STMVolume(
178
+ self._pubsub_client,
179
+ self._volumes,
180
+ self._logger,
181
+ ReactionTimer("volume button", self._logger),
182
+ )
183
+ self._spotify, self._engine = self._create_spotify(hardware)
184
+ self._stmoperation = STMOperation(
185
+ self._pubsub_client,
186
+ self._logger,
187
+ self._rgb_led,
188
+ self._tags,
189
+ self._targets,
190
+ self._activities,
191
+ self._create_stmplayalbum,
192
+ poll_interval=poll_interval,
193
+ latency=self._tag_latency,
194
+ radio_factory=self._create_playradio,
195
+ sync=self._sync,
196
+ spotify_factory=self._create_playspotify,
197
+ speaker=self._engine,
198
+ )
199
+ self._headset = Headset(
200
+ self._pubsub_client,
201
+ self._logger,
202
+ self._rgb_led,
203
+ self._output,
204
+ self._volumes,
205
+ headphones_control,
206
+ self._stmoperation,
207
+ )
208
+
209
+ images = Images(images_folder)
210
+ self._sync.bind(self._tags, self._targets, self._activities, images, self._limits, self._stmoperation)
211
+ self._sync.on_fit = self._fit_changed
212
+ self._slots, self._health = self._create_versions()
213
+ self.control = BoxControl(
214
+ self._config,
215
+ self._pubsub_client,
216
+ self._volumes,
217
+ self._stmoperation,
218
+ self._tags,
219
+ self._targets,
220
+ self._activities,
221
+ self._logger,
222
+ self._rgb_led,
223
+ images,
224
+ self._headset,
225
+ self._virtual_headset,
226
+ self._sync,
227
+ self._slots,
228
+ data_files=[
229
+ json_files["tags"],
230
+ json_files["targets"],
231
+ os.path.splitext(json_files["targets"])[0] + LAST_ID_SUFFIX,
232
+ json_files["activities"],
233
+ self._volume_file(),
234
+ ],
235
+ drivers=self._driver_manager.states if self._driver_manager is not None else None,
236
+ backups=self._backups_files(),
237
+ restore=self._restore,
238
+ spotify=self._spotify,
239
+ )
240
+ self._cli = CLI(self._add_ui_command, self.control)
241
+ self._rest_api: RestApi | None = None
242
+ if webserver:
243
+ self._rest_api = self._create_page(rest_api_config or {})
244
+ self._logger.info("initialized")
245
+
246
+ def _create_page(self, rest_api_config: Mapping[str, Any]) -> RestApi:
247
+ """The web page of the box; the library learns its port, for the link on its own page."""
248
+ rest_api = RestApi(dict(rest_api_config), self.control, self._targets, self._logger, self._log_buffer)
249
+ self._sync.page_port = rest_api.port
250
+ return rest_api
251
+
252
+ def _expand_folders(self) -> None:
253
+ """The albums folder and the json files may begin with ``~`` (the box on the PC)."""
254
+ self._config["albums-folder"] = os.path.expanduser(self._config["albums-folder"])
255
+ self._config["json"] = {key: os.path.expanduser(value) for key, value in self._config["json"].items()}
256
+
257
+ def _images_folder(self, rest_api_config: Mapping[str, Any]) -> str:
258
+ """The folder of the covers of albums and stations: ``images`` next to the json files unless the config
259
+ names one. The covers of stations that a version before 0.29 kept in the folder ``cards`` are moved there."""
260
+ folder = rest_api_config.get("images-folder")
261
+ if folder:
262
+ return os.path.expanduser(str(folder))
263
+ json_folder = os.path.dirname(self._config["json"]["tags"])
264
+ folder = os.path.join(json_folder, "images")
265
+ moved = take_over(os.path.join(json_folder, "cards"), folder)
266
+ if moved:
267
+ self._logger.info("%d covers of radio stations moved from the folder 'cards' to '%s'", moved, folder)
268
+ return folder
269
+
270
+ def _backups_files(self) -> tuple[str, str]:
271
+ """Where the box keeps the backups that it makes by itself - ``backups`` beside the albums folder unless the
272
+ config names a folder - and the file of their schedules, next to the json files."""
273
+ folder = self._config.get("backups-folder") or os.path.join(
274
+ os.path.dirname(os.path.abspath(self._config["albums-folder"])), "backups"
275
+ )
276
+ return os.path.expanduser(str(folder)), os.path.join(
277
+ os.path.dirname(self._config["json"]["tags"]), "backups.json"
278
+ )
279
+
280
+ def _volume_file(self) -> str:
281
+ json_files = self._config["json"]
282
+ return str(json_files.get("volume") or os.path.join(os.path.dirname(json_files["tags"]), "volume.json"))
283
+
284
+ def _create_sync(self, images_folder: str) -> BoxSync:
285
+ """The synchronization with the library (section ``sync``). Its folders lie next to the folder of the json
286
+ files unless the config says otherwise."""
287
+ json_files = self._config["json"]
288
+ base = os.path.dirname(os.path.dirname(os.path.abspath(json_files["tags"])))
289
+ config = self._config.get("sync", {})
290
+ folder = os.path.expanduser(config.get("folder") or os.path.join(base, "sync"))
291
+ # a backup that is put back: a swap of the folders that a power cut interrupted is finished before anything
292
+ # is read
293
+ self._restore = self._create_restore(folder, images_folder)
294
+ self._restore.finish()
295
+ sync = BoxSync(
296
+ folder,
297
+ os.path.expanduser(config.get("identity") or os.path.join(base, "identity")),
298
+ __version__,
299
+ get_child(self._logger, "sync"),
300
+ self._config["albums-folder"],
301
+ images_folder,
302
+ float(config.get("retry-seconds", 30.0)),
303
+ int(float(config.get("keep-free", 500)) * 2**20),
304
+ )
305
+ sync.prepare(
306
+ {
307
+ "tag": json_files["tags"],
308
+ "target": json_files["targets"],
309
+ "position": json_files["activities"],
310
+ "volume": self._volume_file(),
311
+ }
312
+ )
313
+ sync.diagnostics = self._diagnostics
314
+ self._diagnostics.urgent = sync.link.wake # an error goes to the library with the next pass, at once
315
+ return sync
316
+
317
+ def _create_restore(self, sync_folder: str, images_folder: str) -> Restore:
318
+ """Putting a backup back (tinyjukebox.restore): the json files, the images and the albums. What a restore does
319
+ not replace stays: the schedules of the backups and the copies of the json files (``backup``), and the folders
320
+ ``identity`` and ``sync`` - a box stays the box it is for its library."""
321
+ json_folder = os.path.dirname(self._config["json"]["tags"])
322
+ albums = self._config["albums-folder"]
323
+ places = [
324
+ Place("json", json_folder, keep=("backups.json", "backup")),
325
+ Place("images", images_folder),
326
+ Place("albums", albums),
327
+ ]
328
+
329
+ def journal(lines: list[dict[str, Any]]) -> None:
330
+ if not self._sync.attached: # with a library the library has the journal
331
+ self._sync.data.replace_journal(lines)
332
+
333
+ def missing() -> list[dict[str, str]] | None:
334
+ if self._sync.attached: # with a library an album that is not here is fetched
335
+ return None
336
+ return missing_albums(read_json(self._config["json"]["targets"], {}), albums)
337
+
338
+ return Restore("box", places, sync_folder, self._logger, journal, missing)
339
+
340
+ def _create_versions(self) -> tuple[Slots, Health]:
341
+ """Which version runs and its health check."""
342
+ self._slots = Slots(self._logger)
343
+ return self._slots, self._create_health()
344
+
345
+ def _create_spotify(self, hardware: bool) -> tuple[SpotifyAccount, SpotifyEngine]:
346
+ """Key, player and login of Spotify - in the folder ``spotify`` next to the folder of the json files - and the
347
+ Soloist of the box."""
348
+ base = os.path.dirname(os.path.dirname(os.path.abspath(self._config["json"]["tags"])))
349
+ account = SpotifyAccount(os.path.join(base, "spotify"), self._logger, self._spotify_name, manage_avahi=hardware)
350
+ return account, SpotifyEngine(account, self._output, self._logger, self._spotify_name)
351
+
352
+ def _create_playspotify(self, entry: TargetSpotify, activity: ActivityAlbum | None) -> PlaySpotify:
353
+ """The playback of an entry of Spotify (``activity`` None: a test)."""
354
+ return PlaySpotify(
355
+ self._logger,
356
+ self._rgb_led,
357
+ entry,
358
+ activity,
359
+ lambda logger, uri: SpotifyPlayer(logger, uri, self._engine),
360
+ self._pubsub_client,
361
+ float(self._config["albumplayer"].get("update-schedule", 5)),
362
+ check=self._spotify_ready,
363
+ lost=self._spotify.lost,
364
+ test=activity is None,
365
+ )
366
+
367
+ def _spotify_ready(self) -> None:
368
+ """:raises Unavailable: if Spotify cannot play on this box - the tag ends as "cannot be played"."""
369
+ self._spotify.usable()
370
+ if not self._spotify.connected:
371
+ raise Unavailable("not_connected", "the box is not connected to an account of Spotify.")
372
+
373
+ def _spotify_name(self) -> str:
374
+ """The name of the box in the Spotify app: its name in the library, or its host name."""
375
+ name = self._sync.info().get("name") if self._sync.attached else None
376
+ return str(name or socket.gethostname())
377
+
378
+ def _create_health(self) -> Health:
379
+ """Which version runs, and whether it is healthy - only on a box that was set up with slots."""
380
+ config = self._config.get("sync", {})
381
+ health = Health(
382
+ self._slots,
383
+ self._logger,
384
+ float(config.get("stage1-seconds", 10.0)),
385
+ float(config.get("stage2-seconds", 60.0)),
386
+ self._sync.journal,
387
+ look=(LOOK[0], float(config.get("look-hours", LOOK[1] / 3600)) * 3600, LOOK[2]),
388
+ alone=lambda: not self._sync.library,
389
+ )
390
+ health.on_change = self._sync.tell
391
+ self._sync.slots, self._sync.health = self._slots, health
392
+ return health
393
+
394
+ def _fit_changed(self) -> None:
395
+ """Box and library fit together, or not any more. A library that is too old for the box makes the box stop
396
+ (docs/developer/library.md, section 4.15): it plays nothing and shows the alarm pattern."""
397
+ if self._sync.blocked:
398
+ self._rgb_led.show_overlay_sequence(ALARM)
399
+ else:
400
+ self._rgb_led.clear_overlay()
401
+ self._stmoperation.recheck_tag()
402
+
403
+ def _playback_changed(self, message: object) -> None:
404
+ """A pause or the end of a playback: the library hears of the position. Something plays because of a tag:
405
+ the proof that a new version works."""
406
+ if message in (PlaybackStatus.PAUSED, PlaybackStatus.STOPPED):
407
+ self._sync.tell()
408
+ elif message == PlaybackStatus.PLAYING:
409
+ uid = self._stmoperation.get_current_uid() # none: a radio test, which no tag started
410
+ tag = self._tags.tags.get(uid) if uid is not None else None
411
+ if tag is not None and tag.target_type is not None:
412
+ self._health.played(tag.target_type.name)
413
+
414
+ def _journal(self, message: object) -> None:
415
+ """What the state machines note about the playback goes into the journal of the box, with the output it
416
+ played on - the library turns the journal into the play statistics."""
417
+ if isinstance(message, JournalEvent):
418
+ fields = dict(message.fields)
419
+ if message.event == "tag":
420
+ fields["output"] = self._volumes.active.value
421
+ self._sync.journal(message.event, **fields)
422
+
423
+ def _missing(self) -> list[str]:
424
+ """What the config asks for and does not run - stage 1 of the health check of a new version."""
425
+ missing = []
426
+ if self._driver_manager is not None:
427
+ missing += [name for name, driver in self._driver_manager.drivers.items() if not driver.healthy()]
428
+ if self._rest_api is not None and not self._rest_api.running:
429
+ missing.append("web page")
430
+ return missing
431
+
432
+ def _create_volumes(self, hardware: bool, headphones_control: BluetoothVolumeControl) -> Volumes:
433
+ """The volumes of speaker and headset. Start value and maximum: the config - or what has been set at runtime
434
+ (volume json file)."""
435
+ volume_control: VolumeControl
436
+ if hardware:
437
+ volume_control = AlsaVolumeControl(self._config["volume"].get("mixer-control", "Master"), self._logger)
438
+ self._mixer_watch = volume_control
439
+ else:
440
+ volume_control = VirtualVolumeControl(self._logger)
441
+ volume_config = self._config["volume"]
442
+ limits = self._limits
443
+ steps = {key: volume_config[key] for key in ("volume-step", "volume-step-hold") if key in volume_config}
444
+ headphones_config = {**HEADPHONES_DEFAULTS, **self._config.get("bluetooth", {}), **steps}
445
+ return Volumes(
446
+ VolumeManager(volume_control, limits.apply(VolumeProfile.SPEAKER.value, volume_config), self._logger),
447
+ VolumeManager(
448
+ headphones_control,
449
+ limits.apply(VolumeProfile.HEADPHONES.value, headphones_config),
450
+ self._logger,
451
+ "VolumeManager.headphones",
452
+ ),
453
+ limits,
454
+ self._logger,
455
+ )
456
+
457
+ def _create_stmplayalbum(self, album: TargetAlbum, activity: ActivityAlbum) -> STMPlayAlbum:
458
+ return STMPlayAlbum(
459
+ self._config["albumplayer"],
460
+ self._pubsub_client,
461
+ self._logger,
462
+ self._rgb_led,
463
+ album,
464
+ activity,
465
+ self._player_factory,
466
+ self._sync.files if self._sync.attached else None,
467
+ )
468
+
469
+ def _create_playradio(self, station: TargetRadio) -> PlayRadio:
470
+ return PlayRadio(self._logger, self._rgb_led, station, self._radio_player_factory, self._pubsub_client)
471
+
472
+ @property
473
+ def stmoperation(self) -> STMOperation:
474
+ return self._stmoperation
475
+
476
+ @property
477
+ def rest_api(self) -> RestApi | None:
478
+ return self._rest_api
479
+
480
+ @classmethod
481
+ def _get_schema(cls) -> dict[str, Any]:
482
+ return get_schema()
483
+
484
+ @classmethod
485
+ def get_schema(cls) -> dict[str, Any]:
486
+ """Complete schema; the section ``drivers`` is optional (not needed with ``--no-hardware``)."""
487
+ schema = super().get_schema()
488
+ schema["properties"].update(get_drivers_schema())
489
+ return schema
490
+
491
+ @classmethod
492
+ def _get_description(cls) -> str:
493
+ return "RFID-controlled audio player for albums and internet radio."
494
+
495
+ def _start(self) -> None:
496
+ if self._log_buffer is not None and self._log_buffer not in self._logger.handlers:
497
+ self._logger.addHandler(self._log_buffer) # restart after stop
498
+ if self._diagnostics not in self._logger.handlers:
499
+ self._logger.addHandler(self._diagnostics)
500
+ self._headset.start() # before the drivers: the bluetooth driver reports its state when it starts
501
+ if self._driver_manager is not None:
502
+ self._driver_manager.start() # before the first message to the led
503
+ self._rgb_led.set_color(Color.MAGENTA)
504
+ self._stmvolume.start()
505
+ if self._mixer_watch is not None:
506
+ self._mixer_watch.watch(self._volumes.manager(VolumeProfile.SPEAKER).mixer_changed)
507
+ self._rgb_led.set_color(Color.GREEN)
508
+ if self._sync.blocked:
509
+ self._rgb_led.show_overlay_sequence(ALARM)
510
+ self._stmoperation.start()
511
+ self._pubsub_client.subscribe(PLAYBACK, self._playback_changed)
512
+ self._pubsub_client.subscribe(JOURNAL, self._journal)
513
+ self._sync.journal(
514
+ "start", slot=self._health.slot, state="probation" if self._health.probation else "confirmed"
515
+ )
516
+ if not self._sync.attached:
517
+ # with a library the box gets the covers from there, and the stored ones go with their albums
518
+ self.control.adopt_stored_covers()
519
+ self._sync.start()
520
+ if self.control.backups is not None:
521
+ self.control.backups.start()
522
+ self._spotify.start()
523
+ self._health.start(self._missing)
524
+ if self._virtual_headset is not None:
525
+ self._virtual_headset.start()
526
+ if self._rest_api is not None:
527
+ self._rest_api.start()
528
+ self._restore.clean() # the data of before a restore, now that the restored data runs
529
+
530
+ def _stop(self) -> None:
531
+ """Every step is taken, also when one before failed - e.g. an album that cannot save its position on a card
532
+ that became read-only: the led turns red, the drivers stop, no thread is left. The first error is raised
533
+ afterwards."""
534
+ failures: list[Exception] = []
535
+
536
+ def step(action: Callable[[], object]) -> None:
537
+ try:
538
+ action()
539
+ except Exception as error:
540
+ self._logger.exception("stopping: %s failed - the rest is stopped all the same", action)
541
+ failures.append(error)
542
+
543
+ def stop_drivers() -> None:
544
+ if isinstance(self._pubsub_client, LocalQueue):
545
+ self._pubsub_client.wait_until_delivered() # the led must receive the last message
546
+ assert self._driver_manager is not None # noqa: S101 - only called with drivers
547
+ self._driver_manager.stop()
548
+
549
+ step(self._slots.wait_for_command) # a command of the page that asked for this restart gets its answer first
550
+ step(self._health.stop)
551
+ if self._rest_api is not None:
552
+ step(self._rest_api.stop)
553
+ if self._virtual_headset is not None:
554
+ step(self._virtual_headset.stop)
555
+ if self.control.backups is not None:
556
+ step(self.control.backups.stop)
557
+ step(self._engine.stop)
558
+ step(self._spotify.stop)
559
+ step(self._sync.stop)
560
+ self._pubsub_client.unsubscribe(PLAYBACK, self._playback_changed, ignore_not_found=True)
561
+ self._pubsub_client.unsubscribe(JOURNAL, self._journal, ignore_not_found=True)
562
+ step(self._stmoperation.stop)
563
+ if self._mixer_watch is not None:
564
+ step(self._mixer_watch.stop_watching)
565
+ step(self._stmvolume.stop)
566
+ step(self._headset.stop)
567
+ step(lambda: self._rgb_led.stop(Color.RED))
568
+ if self._driver_manager is not None:
569
+ step(stop_drivers)
570
+ if self._log_buffer is not None:
571
+ self._logger.removeHandler(self._log_buffer)
572
+ self._logger.removeHandler(self._diagnostics)
573
+ if failures:
574
+ raise failures[0]
575
+
576
+ @classmethod
577
+ def _argument_parser(cls) -> argparse.ArgumentParser:
578
+ parser = super()._argument_parser()
579
+ parser.add_argument(
580
+ "--hardware",
581
+ action=argparse.BooleanOptionalAction,
582
+ default=True,
583
+ help="--no-hardware: run without device drivers and alsa mixer (virtual volume) - tags and buttons are "
584
+ "then operated via the web page",
585
+ )
586
+ parser.add_argument(
587
+ "--webserver",
588
+ action=argparse.BooleanOptionalAction,
589
+ default=None,
590
+ help="enable/disable the web page and rest api (default: as configured in rest-api)",
591
+ )
592
+ parser.add_argument(
593
+ "--audio",
594
+ action=argparse.BooleanOptionalAction,
595
+ default=True,
596
+ help="--no-audio: tracks are played in real time without sound output (no sound card needed)",
597
+ )
598
+ for action in parser._actions: # -c or --data
599
+ if "--config" in action.option_strings:
600
+ action.required = False
601
+ parser.add_argument(
602
+ "--data",
603
+ metavar="DIR",
604
+ help="a box without hardware with everything below this folder, without a config file of its own "
605
+ "(excludes -c)",
606
+ )
607
+ return parser
608
+
609
+ @classmethod
610
+ def standalone(cls, args: Sequence[str] | None = None) -> None:
611
+ """Command line entry point: the common options plus --[no-]hardware, --[no-]webserver, --[no-]audio."""
612
+ parser = cls._argument_parser()
613
+ arguments = parser.parse_args(args)
614
+ if bool(arguments.config) == bool(arguments.data):
615
+ parser.error("one of -c/--config and --data is needed")
616
+ stdout_log_level = "" if arguments.quiet else "INFO" if arguments.verbose else "WARNING"
617
+ if arguments.data:
618
+ config = data_folder.box_config(arguments.data)
619
+ error = core_config.validate_config(config, cls.get_schema())
620
+ if error:
621
+ raise SystemExit(f"{arguments.data}: {error}")
622
+ data_folder.link_library(arguments.data, logging.getLogger("tinyjukebox"))
623
+ data_folder.first_admin(config["tinyjukebox"]["rest-api"]["users-file"], logging.getLogger("tinyjukebox"))
624
+ if arguments.webserver is None:
625
+ arguments.webserver = True
626
+ else:
627
+ config = cls._read_config(arguments.config)
628
+ instance = None
629
+ for signum in (signal.SIGTERM, signal.SIGHUP):
630
+ signal.signal(signum, _stop_on_signal)
631
+ try:
632
+ instance = cls(
633
+ config,
634
+ stdout_log_level=stdout_log_level,
635
+ no_gui=arguments.no_gui,
636
+ hardware=arguments.hardware,
637
+ webserver=arguments.webserver,
638
+ audio=arguments.audio,
639
+ )
640
+ # a backup that is to be put back: the box stops as on SIGTERM, the folders are swapped while nothing
641
+ # uses them, and systemd starts the box again
642
+ instance._restore.on_confirm = _stop_soon
643
+ instance.run()
644
+ if instance._restore.confirmed:
645
+ instance._restore.apply()
646
+ raise SystemExit(RESTART)
647
+ except Exception:
648
+ if instance is not None:
649
+ instance._logger.exception("%s terminated with an exception", cls.__name__)
650
+ raise
651
+
652
+
653
+ def standalone() -> None:
654
+ """Console script ``tinyjukebox``."""
655
+ try:
656
+ TinyJukebox.standalone()
657
+ except KeyboardInterrupt:
658
+ raise
659
+ except SystemExit as error:
660
+ if error.code not in (None, 0, RESTART):
661
+ _tell_start_script(error)
662
+ raise
663
+ except BaseException as error:
664
+ _tell_start_script(error)
665
+ raise
666
+
667
+
668
+ def _tell_start_script(error: BaseException) -> None:
669
+ """Why the application ends with an error - mostly: why it could not start. The start script sends it to the
670
+ library while the box cannot start (docs/developer/library.md, section 4.22). Only on a box with slots."""
671
+ command = shutil.which("tinyjukebox-slot")
672
+ if not os.environ.get("TINYJUKEBOX_BASE") or command is None:
673
+ return
674
+ text = redact("".join(traceback.format_exception(error)))[-MAX_TRACEBACK:]
675
+ with contextlib.suppress(OSError, subprocess.SubprocessError):
676
+ subprocess.run([command, "_error", text], capture_output=True, timeout=10, check=False)
677
+
678
+
679
+ if __name__ == "__main__":
680
+ standalone()
@@ -0,0 +1 @@
1
+ """Command line tools of the Tiny Jukebox box."""