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