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,83 @@
|
|
|
1
|
+
"""Schema of the targets json file."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
__all__ = ["schema"]
|
|
6
|
+
|
|
7
|
+
_COMMON: dict[str, Any] = {
|
|
8
|
+
"target-id": {"description": "same as the key of this entry", "type": "string"},
|
|
9
|
+
"created": {"description": "timestamp when this entry has been added", "type": "string"},
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
schema: dict[str, Any] = {
|
|
13
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": {
|
|
16
|
+
"oneOf": [
|
|
17
|
+
{
|
|
18
|
+
"description": "album",
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["target-id", "target-type", "album-folder", "playlist", "created"],
|
|
21
|
+
"additionalProperties": False,
|
|
22
|
+
"properties": {
|
|
23
|
+
**_COMMON,
|
|
24
|
+
"target-type": {"description": "type of the target", "type": "string", "enum": ["album"]},
|
|
25
|
+
"album-folder": {
|
|
26
|
+
"description": "folder of the album (relative to albums-folder) with the mp3 files and the "
|
|
27
|
+
"playlist",
|
|
28
|
+
"type": "string",
|
|
29
|
+
},
|
|
30
|
+
"playlist": {"description": "m3u playlist in the album folder", "type": "string"},
|
|
31
|
+
"title": {
|
|
32
|
+
"description": "title of the album - without it the title comes from the tracks or the folder",
|
|
33
|
+
"type": "string",
|
|
34
|
+
},
|
|
35
|
+
"cover": {
|
|
36
|
+
"description": "cover of the album: name of an image in the images folder - without it the "
|
|
37
|
+
"album shows an image of its folder or of its tracks",
|
|
38
|
+
"type": "string",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"description": "internet radio station",
|
|
44
|
+
"type": "object",
|
|
45
|
+
"required": ["target-id", "target-type", "name", "url", "created"],
|
|
46
|
+
"additionalProperties": False,
|
|
47
|
+
"properties": {
|
|
48
|
+
**_COMMON,
|
|
49
|
+
"target-type": {"description": "type of the target", "type": "string", "enum": ["radio"]},
|
|
50
|
+
"name": {"description": "name of the station", "type": "string"},
|
|
51
|
+
"url": {
|
|
52
|
+
"description": "http(s) url of the stream or of an m3u/pls playlist with the stream",
|
|
53
|
+
"type": "string",
|
|
54
|
+
},
|
|
55
|
+
"cover": {
|
|
56
|
+
"description": "cover of the station: name of an image in the images folder",
|
|
57
|
+
"type": "string",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"description": "entry of Spotify: an album, playlist, track, episode, podcast, audiobook or artist",
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["target-id", "target-type", "name", "uri", "created"],
|
|
65
|
+
"additionalProperties": False,
|
|
66
|
+
"properties": {
|
|
67
|
+
**_COMMON,
|
|
68
|
+
"target-type": {"description": "type of the target", "type": "string", "enum": ["spotify"]},
|
|
69
|
+
"name": {"description": "name of the entry", "type": "string"},
|
|
70
|
+
"uri": {
|
|
71
|
+
"description": "address that Spotify plays: spotify:<kind>:<id>",
|
|
72
|
+
"type": "string",
|
|
73
|
+
"pattern": "^spotify:(album|playlist|track|episode|show|audiobook|artist):[A-Za-z0-9]{1,64}$",
|
|
74
|
+
},
|
|
75
|
+
"cover": {
|
|
76
|
+
"description": "cover of the entry: name of an image in the images folder",
|
|
77
|
+
"type": "string",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Schema of the config section ``tinyjukebox``."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
__all__ = ["get_schema"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _string(description: str) -> dict[str, str]:
|
|
9
|
+
return {"description": description, "type": "string"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_schema() -> dict[str, Any]:
|
|
13
|
+
"""``{"tinyjukebox": schema}`` - combined with the sections ``logger`` and ``drivers`` by
|
|
14
|
+
``TinyJukebox.get_schema()``."""
|
|
15
|
+
return {
|
|
16
|
+
"tinyjukebox": {
|
|
17
|
+
"description": "Tiny Jukebox box",
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"albums-folder": _string(
|
|
21
|
+
"root folder of all albums - album folders in the targets json are relative to it"
|
|
22
|
+
),
|
|
23
|
+
"backups-folder": _string(
|
|
24
|
+
"folder of the backups that a box without a library makes by itself, until another folder is set "
|
|
25
|
+
"on its page (optional, default: folder 'backups' beside the albums folder)"
|
|
26
|
+
),
|
|
27
|
+
"playlist-filename": _string("name of the m3u playlist in each album folder (e.g. playlist.m3u)"),
|
|
28
|
+
"volume": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"max-volume": {"description": "maximum volume in %", "type": "number"},
|
|
32
|
+
"default-volume": {"description": "volume on startup in %", "type": "number"},
|
|
33
|
+
"volume-step": {"description": "change of the volume per button press in %", "type": "number"},
|
|
34
|
+
"volume-step-hold": {
|
|
35
|
+
"description": "change of the volume in % per hold event of a volume button that is kept "
|
|
36
|
+
"pressed (optional, default: 1) - 'hold_timeout' and 'hold_repeat' of its input driver "
|
|
37
|
+
"say how often",
|
|
38
|
+
"type": "number",
|
|
39
|
+
"minimum": 0,
|
|
40
|
+
},
|
|
41
|
+
"mixer-control": _string(
|
|
42
|
+
"alsa mixer control of the default card (default: Master) - list: 'amixer scontrols'"
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
"required": ["max-volume", "default-volume", "volume-step"],
|
|
46
|
+
"additionalProperties": False,
|
|
47
|
+
},
|
|
48
|
+
"bluetooth": {
|
|
49
|
+
"description": "volume of the bluetooth headset (optional) - start value and maximum of speaker "
|
|
50
|
+
"and headset can be changed at runtime (web page, command line); the changed values are stored "
|
|
51
|
+
"in the volume json file and apply instead of the config",
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"default-volume": {
|
|
55
|
+
"description": "volume in % whenever the headset connects (default: 40)",
|
|
56
|
+
"type": "number",
|
|
57
|
+
"minimum": 0,
|
|
58
|
+
"maximum": 100,
|
|
59
|
+
},
|
|
60
|
+
"max-volume": {
|
|
61
|
+
"description": "maximum volume of the headset in % (default: 75) - 100: the box does "
|
|
62
|
+
"not limit it",
|
|
63
|
+
"type": "number",
|
|
64
|
+
"minimum": 0,
|
|
65
|
+
"maximum": 100,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"additionalProperties": False,
|
|
69
|
+
},
|
|
70
|
+
"albumplayer": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"rewind-max-time": {
|
|
74
|
+
"description": "'previous' within this many seconds of a track goes to the previous "
|
|
75
|
+
"track, later it restarts the current track",
|
|
76
|
+
"type": "number",
|
|
77
|
+
},
|
|
78
|
+
"update-schedule": {
|
|
79
|
+
"description": "store the playback position every n seconds",
|
|
80
|
+
"type": "number",
|
|
81
|
+
"exclusiveMinimum": 0,
|
|
82
|
+
},
|
|
83
|
+
"pause-between-tracks": {
|
|
84
|
+
"description": "pause between two tracks in seconds",
|
|
85
|
+
"type": "number",
|
|
86
|
+
"minimum": 0,
|
|
87
|
+
},
|
|
88
|
+
"buffer-time": {
|
|
89
|
+
"description": "alsa buffer of aplay in ms (optional, default: aplay's default, usually "
|
|
90
|
+
"500 ms) - smaller starts the sound faster; raise it if the sound stutters",
|
|
91
|
+
"type": "number",
|
|
92
|
+
"minimum": 20,
|
|
93
|
+
"maximum": 2000,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
"required": ["rewind-max-time", "update-schedule", "pause-between-tracks"],
|
|
97
|
+
"additionalProperties": False,
|
|
98
|
+
},
|
|
99
|
+
"json": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"tags": _string("tags json file, e.g. /srv/tinyjukebox/json/tags.json"),
|
|
103
|
+
"targets": _string("targets (albums) json file, e.g. /srv/tinyjukebox/json/targets.json"),
|
|
104
|
+
"activities": _string("activities json file, e.g. /srv/tinyjukebox/json/activity.json"),
|
|
105
|
+
"volume": _string(
|
|
106
|
+
"start value and maximum of the volumes changed at runtime (optional, default: "
|
|
107
|
+
"volume.json next to the tags json)"
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
"required": ["tags", "targets", "activities"],
|
|
111
|
+
"additionalProperties": False,
|
|
112
|
+
},
|
|
113
|
+
"sync": {
|
|
114
|
+
"description": "synchronization with the library (optional) - the box works with a library as "
|
|
115
|
+
"soon as its address was entered (web page, or the file 'library' in the identity folder)",
|
|
116
|
+
"type": "object",
|
|
117
|
+
"properties": {
|
|
118
|
+
"folder": _string(
|
|
119
|
+
"what the library delivered, the changes of this box that it has not confirmed, the "
|
|
120
|
+
"journal (default: folder 'sync' next to the folder of the tags json)"
|
|
121
|
+
),
|
|
122
|
+
"identity": _string(
|
|
123
|
+
"what identifies this box: its id, its key, address and certificate of the library "
|
|
124
|
+
"(default: folder 'identity' next to the folder of the tags json)"
|
|
125
|
+
),
|
|
126
|
+
"retry-seconds": {
|
|
127
|
+
"description": "how often the library is tried again while it cannot be reached "
|
|
128
|
+
"(default: 30)",
|
|
129
|
+
"type": "number",
|
|
130
|
+
"exclusiveMinimum": 0,
|
|
131
|
+
},
|
|
132
|
+
"keep-free": {
|
|
133
|
+
"description": "MB that stay free on the card of a box that works with a library "
|
|
134
|
+
"(default: 500, 0: never delete): when less is free, the audio files of albums are "
|
|
135
|
+
"deleted here, the album not played for the longest time first - the library has them, "
|
|
136
|
+
"and they are fetched again when their tag is placed",
|
|
137
|
+
"type": "number",
|
|
138
|
+
"minimum": 0,
|
|
139
|
+
},
|
|
140
|
+
"stage1-seconds": {
|
|
141
|
+
"description": "health check of a new version: seconds after the start in which no "
|
|
142
|
+
"unexpected exception may occur and everything must run (default: 10)",
|
|
143
|
+
"type": "number",
|
|
144
|
+
"exclusiveMinimum": 0,
|
|
145
|
+
},
|
|
146
|
+
"stage2-seconds": {
|
|
147
|
+
"description": "health check of a new version: seconds an album must have played "
|
|
148
|
+
"without an unexpected exception before the version is confirmed (default: 60)",
|
|
149
|
+
"type": "number",
|
|
150
|
+
"exclusiveMinimum": 0,
|
|
151
|
+
},
|
|
152
|
+
"look-hours": {
|
|
153
|
+
"description": "a box with slots and without a library: hours between two looks for a "
|
|
154
|
+
"new version at the releases of the git server (default: 24)",
|
|
155
|
+
"type": "number",
|
|
156
|
+
"exclusiveMinimum": 0,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
"additionalProperties": False,
|
|
160
|
+
},
|
|
161
|
+
"rest-api": {
|
|
162
|
+
"description": "https rest api and web page (optional)",
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": {
|
|
165
|
+
"enabled": {"description": "default: true", "type": "boolean"},
|
|
166
|
+
"host": _string("address to listen on (default: 0.0.0.0)"),
|
|
167
|
+
"port": {"description": "default: 8443", "type": "integer", "minimum": 0, "maximum": 65535},
|
|
168
|
+
"certificate": _string(
|
|
169
|
+
"certificate file (pem) - a self signed certificate is created if it "
|
|
170
|
+
"does not exist (default: ~/.config/tinyjukebox/tinyjukebox.crt)"
|
|
171
|
+
),
|
|
172
|
+
"private-key": _string(
|
|
173
|
+
"private key file (pem, default: ~/.config/tinyjukebox/tinyjukebox.key)"
|
|
174
|
+
),
|
|
175
|
+
"hostnames": {
|
|
176
|
+
"description": "additional host names / ip addresses for the self signed certificate",
|
|
177
|
+
"type": "array",
|
|
178
|
+
"items": {"type": "string"},
|
|
179
|
+
},
|
|
180
|
+
"users-file": _string(
|
|
181
|
+
"users and password hashes - manage with 'tinyjukebox-users' "
|
|
182
|
+
"(default: ~/.config/tinyjukebox/users.yaml)"
|
|
183
|
+
),
|
|
184
|
+
"images-folder": _string(
|
|
185
|
+
"covers of albums, radio stations and entries of Spotify (default: folder 'images' next "
|
|
186
|
+
"to the tags json)"
|
|
187
|
+
),
|
|
188
|
+
"session-timeout": {
|
|
189
|
+
"description": "logout after this many seconds without request (default: 604800 = 7 days)",
|
|
190
|
+
"type": "number",
|
|
191
|
+
"exclusiveMinimum": 0,
|
|
192
|
+
},
|
|
193
|
+
"idle-timeout": {
|
|
194
|
+
"description": "logout after this many seconds in which the user of the page did nothing "
|
|
195
|
+
"(default: 1800 = 30 minutes; 0: never)",
|
|
196
|
+
"type": "number",
|
|
197
|
+
"minimum": 0,
|
|
198
|
+
},
|
|
199
|
+
"max-upload-size": {
|
|
200
|
+
"description": "maximum size of an uploaded mp3 file in MB (default: 500)",
|
|
201
|
+
"type": "number",
|
|
202
|
+
"exclusiveMinimum": 0,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
"additionalProperties": False,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
"required": [
|
|
209
|
+
"volume",
|
|
210
|
+
"albums-folder",
|
|
211
|
+
"playlist-filename",
|
|
212
|
+
"albumplayer",
|
|
213
|
+
"json",
|
|
214
|
+
],
|
|
215
|
+
"additionalProperties": False,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Schema of the volume json file (start value and maximum changed at runtime)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
__all__ = ["schema"]
|
|
6
|
+
|
|
7
|
+
_PERCENT = {"type": "number", "minimum": 0, "maximum": 100}
|
|
8
|
+
_LIMITS = {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"required": ["default-volume", "max-volume"],
|
|
11
|
+
"additionalProperties": False,
|
|
12
|
+
"properties": {
|
|
13
|
+
"default-volume": dict(_PERCENT, description="volume on startup / when the headset connects in %"),
|
|
14
|
+
"max-volume": dict(_PERCENT, description="maximum volume in %"),
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
schema: dict[str, Any] = {
|
|
19
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": False,
|
|
22
|
+
"properties": {
|
|
23
|
+
"speaker": dict(_LIMITS, description="volume of the speaker - overrides tinyjukebox/volume of the config"),
|
|
24
|
+
"headphones": dict(
|
|
25
|
+
_LIMITS, description="volume of the headset - overrides tinyjukebox/bluetooth of the config"
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
}
|
tinyjukebox/slots.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Which version of the box software runs, and whether it is healthy (docs/developer/library.md, section 6).
|
|
2
|
+
|
|
3
|
+
The box has slots with a version each; the start script and ``tinyjukebox-slot`` (deploy/slot, outside the slots)
|
|
4
|
+
install, switch and go back. They do the "how". The application does the "when": it asks the script to look for a
|
|
5
|
+
version, and it tells the script how the health check of a new version went:
|
|
6
|
+
|
|
7
|
+
- stage 1, seconds after the start: the software runs, everything the config asks for runs, and no unexpected
|
|
8
|
+
exception arrived at the central place (:mod:`tinyjukebox.core.unexpected`),
|
|
9
|
+
- stage 2, in use: an album was played because of a tag, and a configured time passed after that without an
|
|
10
|
+
unexpected exception.
|
|
11
|
+
|
|
12
|
+
A version on probation that fails ends at once; the start script starts the previous version. On a box without slots -
|
|
13
|
+
the PC, a box that was not set up with them - nothing of this happens.
|
|
14
|
+
|
|
15
|
+
A library announces a new version. A box without a library has nobody who does: the application lets the script
|
|
16
|
+
look after the start and once a day (section 6.16).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import threading
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from tinyjukebox.core import unexpected
|
|
29
|
+
from tinyjukebox.core.logging import get_child
|
|
30
|
+
|
|
31
|
+
__all__ = ["COMMANDS", "DEFECT", "LOOK", "Health", "Slots"]
|
|
32
|
+
|
|
33
|
+
DEFECT = 70 # exit status of a version that ends itself because its health check failed
|
|
34
|
+
NO_ANSWER = 69 # exit status of "tinyjukebox-slot update --auto" if the releases did not answer: ask again soon
|
|
35
|
+
LOOK = (60.0, 24 * 3600.0, 600.0)
|
|
36
|
+
"""Seconds until a box without a library looks for a version: after the start, between two looks, and after a look
|
|
37
|
+
that got no answer."""
|
|
38
|
+
COMMANDS = ("check", "update", "revert", "use", "fallback", "promote", "mode", "skip")
|
|
39
|
+
"""What the tab "System" of the web page may ask the slot script for."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Slots:
|
|
43
|
+
"""The slot script as the application uses it. ``state/`` is written by the scripts only."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, logger: logging.Logger, command: str = "tinyjukebox-slot") -> None:
|
|
46
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
47
|
+
self._command = shutil.which(command)
|
|
48
|
+
self._lock = threading.Lock() # held while a command of the page runs
|
|
49
|
+
self.available = False
|
|
50
|
+
if self._command is not None:
|
|
51
|
+
# the script alone is not enough: the box must have been set up with slots, and one must be active
|
|
52
|
+
self.available = bool(self.status().get("active"))
|
|
53
|
+
|
|
54
|
+
def run(self, *arguments: str, timeout: float | None = None) -> subprocess.CompletedProcess[str]:
|
|
55
|
+
"""Call the script. What it prints goes to the log, except for the calls the application makes all the
|
|
56
|
+
time."""
|
|
57
|
+
if self._command is None:
|
|
58
|
+
return subprocess.CompletedProcess(arguments, 127, "", "tinyjukebox-slot is not installed")
|
|
59
|
+
try:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
[self._command, *arguments], capture_output=True, text=True, check=False, timeout=timeout
|
|
62
|
+
)
|
|
63
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
64
|
+
return subprocess.CompletedProcess(arguments, 1, "", str(error))
|
|
65
|
+
if not arguments[0].startswith("_") and arguments[:2] != ("status", "--json"):
|
|
66
|
+
for line in (result.stdout + result.stderr).splitlines():
|
|
67
|
+
self._logger.info("tinyjukebox-slot %s: %s", arguments[0], line)
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def status(self) -> dict[str, Any]:
|
|
71
|
+
"""Slots, active slot, update mode, states of the versions, switches - empty without slots."""
|
|
72
|
+
result = self.run("status", "--json", timeout=30)
|
|
73
|
+
try:
|
|
74
|
+
status = json.loads(result.stdout) if result.returncode == 0 else {}
|
|
75
|
+
except ValueError:
|
|
76
|
+
status = {}
|
|
77
|
+
return status if isinstance(status, dict) else {}
|
|
78
|
+
|
|
79
|
+
def command(self, command: str, arguments: list[str], by: str) -> dict[str, Any]:
|
|
80
|
+
"""A command of the tab "System". The lock makes a restart that the command asks for wait until the page
|
|
81
|
+
has its answer (:meth:`wait_for_command`)."""
|
|
82
|
+
if command not in COMMANDS:
|
|
83
|
+
raise ValueError(f"unknown command '{command}'")
|
|
84
|
+
with self._lock:
|
|
85
|
+
result = self.run(command, "--by", by, *arguments)
|
|
86
|
+
return {"status": result.returncode, "output": result.stdout + result.stderr}
|
|
87
|
+
|
|
88
|
+
def wait_for_command(self, timeout: float = 5.0) -> None:
|
|
89
|
+
"""Called when the service stops: a command of the page may have asked for this restart itself."""
|
|
90
|
+
if self._lock.acquire(timeout=timeout):
|
|
91
|
+
self._lock.release()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Health:
|
|
95
|
+
"""The health check of the running version (section 6.4), and what the application decides about versions."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
slots: Slots,
|
|
100
|
+
logger: logging.Logger,
|
|
101
|
+
stage1_seconds: float = 10.0,
|
|
102
|
+
stage2_seconds: float = 60.0,
|
|
103
|
+
journal: Callable[..., None] | None = None,
|
|
104
|
+
end: Callable[[int], None] = os._exit,
|
|
105
|
+
look: tuple[float, float, float] = LOOK,
|
|
106
|
+
alone: Callable[[], bool] = lambda: False,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""``journal(event, **fields)`` notes what happened for the library; ``end(status)`` ends the process.
|
|
109
|
+
``alone()``: the box is not linked to a library, so it looks for versions by itself, at the times of
|
|
110
|
+
``look`` (:data:`LOOK`)."""
|
|
111
|
+
self._slots = slots
|
|
112
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
113
|
+
self._stage1_seconds = stage1_seconds
|
|
114
|
+
self._stage2_seconds = stage2_seconds
|
|
115
|
+
self._journal = journal or (lambda event, **fields: None)
|
|
116
|
+
self._end = end
|
|
117
|
+
self._look_first, self._look_every, self._look_again = look
|
|
118
|
+
self._alone = alone
|
|
119
|
+
self._lock = threading.Lock()
|
|
120
|
+
self._timers: list[threading.Timer] = []
|
|
121
|
+
self._failed = False
|
|
122
|
+
self._proof = False
|
|
123
|
+
self._wake = threading.Event()
|
|
124
|
+
self._stop = threading.Event()
|
|
125
|
+
self._updater: threading.Thread | None = None
|
|
126
|
+
self.on_change: Callable[[], None] | None = None # the library hears of it at once
|
|
127
|
+
active = slots.status() if slots.available else {}
|
|
128
|
+
self.slot = str(active.get("active", ""))
|
|
129
|
+
self.probation = active.get("slots", {}).get(self.slot, {}).get("state") == "probation"
|
|
130
|
+
|
|
131
|
+
# --- start and stop ----------------------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def start(self, missing: Callable[[], list[str]]) -> None:
|
|
134
|
+
"""The service has started. ``missing()`` names what the config asks for and does not run."""
|
|
135
|
+
unexpected.subscribe(self.unexpected)
|
|
136
|
+
if not self._slots.available:
|
|
137
|
+
return
|
|
138
|
+
self._stop.clear()
|
|
139
|
+
self._timer(self._stage1_seconds, lambda: self._stage1(missing))
|
|
140
|
+
self._updater = threading.Thread(target=self._look, name="Health.updater", daemon=True)
|
|
141
|
+
self._updater.start()
|
|
142
|
+
|
|
143
|
+
def stop(self) -> None:
|
|
144
|
+
unexpected.unsubscribe(self.unexpected)
|
|
145
|
+
self._stop.set()
|
|
146
|
+
self._wake.set()
|
|
147
|
+
with self._lock:
|
|
148
|
+
timers, self._timers = self._timers, []
|
|
149
|
+
for timer in timers:
|
|
150
|
+
timer.cancel()
|
|
151
|
+
if self._updater is not None:
|
|
152
|
+
self._updater.join(timeout=5)
|
|
153
|
+
self._updater = None
|
|
154
|
+
|
|
155
|
+
def _timer(self, seconds: float, action: Callable[[], None]) -> None:
|
|
156
|
+
timer = threading.Timer(seconds, action)
|
|
157
|
+
timer.name = "Health.timer"
|
|
158
|
+
timer.daemon = True
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._timers.append(timer)
|
|
161
|
+
timer.start()
|
|
162
|
+
|
|
163
|
+
# --- the health check --------------------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def unexpected(self, error: BaseException, where: str) -> None:
|
|
166
|
+
"""An exception that nobody expected. During the probation it ends this version; afterwards it is only
|
|
167
|
+
reported to the library."""
|
|
168
|
+
self._failed = True
|
|
169
|
+
try:
|
|
170
|
+
self._journal("unexpected", error=repr(error), where=where, probation=self.probation)
|
|
171
|
+
except Exception: # e.g. the card is full: a version on probation ends all the same
|
|
172
|
+
self._logger.exception("the unexpected exception cannot be noted in the journal")
|
|
173
|
+
if self.probation and self._slots.available:
|
|
174
|
+
self._logger.error("unexpected exception during the probation (%s): this version ends", where)
|
|
175
|
+
self._slots.run("_failure", f"unexpected exception in {where}: {error!r}")
|
|
176
|
+
logging.shutdown()
|
|
177
|
+
self._end(DEFECT)
|
|
178
|
+
elif self.on_change is not None:
|
|
179
|
+
self.on_change()
|
|
180
|
+
|
|
181
|
+
def _stage1(self, missing: Callable[[], list[str]]) -> None:
|
|
182
|
+
lacking = missing()
|
|
183
|
+
if self._failed or lacking:
|
|
184
|
+
# the start script ends a version on probation that does not pass in time
|
|
185
|
+
self._logger.error("stage 1 of the health check not passed: %s", lacking or "unexpected exception")
|
|
186
|
+
return
|
|
187
|
+
self._slots.run("_passed", "1")
|
|
188
|
+
self._logger.info("stage 1 of the health check passed")
|
|
189
|
+
|
|
190
|
+
def played(self, kind: str) -> None:
|
|
191
|
+
"""Something is playing because of a tag. An album confirms a version; a radio station never does, and
|
|
192
|
+
never condemns one: if a stream fails, the cause is mostly the station or the internet."""
|
|
193
|
+
if kind != "album" or not self.probation or self._proof or not self._slots.available:
|
|
194
|
+
return
|
|
195
|
+
self._proof = True
|
|
196
|
+
self._timer(self._stage2_seconds, self._stage2)
|
|
197
|
+
|
|
198
|
+
def _stage2(self) -> None:
|
|
199
|
+
if self._failed or not self.probation:
|
|
200
|
+
return
|
|
201
|
+
self._slots.run("_passed", "2")
|
|
202
|
+
self.probation = False
|
|
203
|
+
self._logger.info("stage 2 of the health check passed: this version is confirmed")
|
|
204
|
+
self._journal("confirmed", slot=self.slot)
|
|
205
|
+
if self.on_change is not None:
|
|
206
|
+
self.on_change()
|
|
207
|
+
|
|
208
|
+
# --- looking for a version ---------------------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def look_for_versions(self) -> None:
|
|
211
|
+
"""The library may have another version, or changed the state of one: let the script find out."""
|
|
212
|
+
self._wake.set()
|
|
213
|
+
|
|
214
|
+
def _look(self) -> None:
|
|
215
|
+
"""The script fetches and installs (section 6.11); it may take minutes, so it has a thread of its own.
|
|
216
|
+
|
|
217
|
+
A box with a library looks when the library says so. A box without one looks by the clock: after the start,
|
|
218
|
+
once a day, and soon again if nobody answered - no WLAN yet, or a clock that is not set."""
|
|
219
|
+
wait = self._look_first
|
|
220
|
+
while True:
|
|
221
|
+
asked = self._wake.wait(wait)
|
|
222
|
+
if self._stop.is_set():
|
|
223
|
+
return
|
|
224
|
+
self._wake.clear()
|
|
225
|
+
wait = self._look_every
|
|
226
|
+
if not asked and not self._alone():
|
|
227
|
+
continue
|
|
228
|
+
result = self._slots.run("update", "--auto")
|
|
229
|
+
if result.returncode == NO_ANSWER:
|
|
230
|
+
wait = self._look_again
|
|
231
|
+
elif result.returncode != 0:
|
|
232
|
+
self._logger.error("looking for a version failed (%d)", result.returncode)
|
|
233
|
+
if self.on_change is not None:
|
|
234
|
+
self.on_change() # report what happened
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Spotify: entries of Spotify on a tag, played with Spotify Soloist (docs/developer/architecture.md, section 4.1)."""
|