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,256 @@
1
+ """All targets (``targets.json``): albums, radio stations and entries of Spotify."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import threading
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from tinyjukebox.core.logging import get_child
11
+ from tinyjukebox.data.common import Listener, Mirror, TargetType, UpdateJson, is_unmapped
12
+ from tinyjukebox.data.jsondata import JsonData
13
+ from tinyjukebox.data.target import Target, TargetAlbum, TargetRadio, TargetSpotify
14
+ from tinyjukebox.schema.targets import schema
15
+
16
+ __all__ = ["LAST_ID_SUFFIX", "Targets", "create_target_factory", "load_target_factory"]
17
+
18
+ LAST_ID_SUFFIX = "-last-id.json"
19
+ """``targets.json`` -> ``targets-last-id.json``: the highest album id given so far."""
20
+
21
+
22
+ def _is_number(target_id: str | None) -> bool:
23
+ return target_id is not None and target_id.isascii() and target_id.isdigit()
24
+
25
+
26
+ def _check_target_dict(target: dict[str, Any] | None, logger: logging.Logger) -> dict[str, Any]:
27
+ if target is None:
28
+ logger.error("target not provided")
29
+ raise ValueError("target not provided")
30
+ if "target-id" not in target:
31
+ logger.error("provided target has no attribute target-id")
32
+ raise ValueError("provided target has no attribute target-id")
33
+ if is_unmapped(target["target-id"]):
34
+ logger.error("target-id must be set.")
35
+ raise ValueError("target-id must be set.")
36
+ if "target-type" not in target:
37
+ raise ValueError("provided target has no attribute target-type")
38
+ if target["target-type"] not in (TargetType.album.name, TargetType.radio.name, TargetType.spotify.name):
39
+ logger.error("unknown target type: %s.", target["target-type"])
40
+ raise ValueError(f"unknown target type: {target['target-type']}.")
41
+ return target
42
+
43
+
44
+ def create_target_factory(
45
+ target: dict[str, Any] | None, root_folder: str, update_json: UpdateJson | None, logger: logging.Logger
46
+ ) -> Target:
47
+ """A new target - checked for sanity (e.g. files exist, valid url). :raises ValueError: otherwise."""
48
+ factory_logger = get_child(logger, "create_target_factory")
49
+ target = _check_target_dict(target, factory_logger)
50
+ if target["target-type"] == TargetType.radio.name:
51
+ return TargetRadio.create_target(target, logger, update_json)
52
+ if target["target-type"] == TargetType.spotify.name:
53
+ return TargetSpotify.create_target(target, logger, update_json)
54
+ return TargetAlbum.create_target(target, root_folder, logger, update_json)
55
+
56
+
57
+ def load_target_factory(
58
+ target: dict[str, Any] | None, root_folder: str, update_json: UpdateJson, logger: logging.Logger
59
+ ) -> Target:
60
+ """A target from the json file - not checked for sanity (a missing album must not prevent the start)."""
61
+ factory_logger = get_child(logger, "load_target_factory")
62
+ target = _check_target_dict(target, factory_logger)
63
+ if target["target-type"] == TargetType.radio.name:
64
+ return TargetRadio(target, logger, update_json)
65
+ if target["target-type"] == TargetType.spotify.name:
66
+ return TargetSpotify(target, logger, update_json)
67
+ return TargetAlbum(target, root_folder, logger, update_json)
68
+
69
+
70
+ class Targets:
71
+ """Targets by id. Changes are written to the json file immediately (see :class:`~tinyjukebox.data.tags.Tags`
72
+ for the locking rules).
73
+
74
+ Albums, radio stations and entries of Spotify share the ids. New ids count up and are never given twice - also
75
+ not after the target with the highest id has been deleted: the new target would inherit everything that still
76
+ refers to the old id (e.g. the cover cached by the browser). The highest id given so far is kept in a file of its
77
+ own (:data:`LAST_ID_SUFFIX`) - older versions would reject an additional entry in the targets json. Without that
78
+ file, new ids follow the highest existing id.
79
+
80
+ With ``id_source`` the ids come from there: the library assigns them for all boxes, and a box that works with a
81
+ library creates a target under a provisional id (:mod:`tinyjukebox.sync`).
82
+ """
83
+
84
+ def __init__(self, filename: str, root_folder: str, logger: logging.Logger) -> None:
85
+ self._logger = get_child(logger, type(self).__name__)
86
+ self._lock = threading.RLock()
87
+ self._json = JsonData(filename, filename + "_", schema, self._logger, entries=True)
88
+ self._last_id_file = os.path.splitext(filename)[0] + LAST_ID_SUFFIX
89
+ self.root_folder = root_folder
90
+ self.id_source: Callable[[], str] | None = None
91
+ self._mirror = Mirror()
92
+ self.targets: dict[str, Target] = {}
93
+ for key, target in list(self._json.data.items()):
94
+ try:
95
+ self.targets[key] = load_target_factory(target, root_folder, self._update_json, self._logger)
96
+ except ValueError as e: # a target this version cannot use does not keep the box from starting
97
+ self._json.set_aside(key, target, str(e))
98
+
99
+ def _to_dict(self) -> dict[str, Any]:
100
+ return {key: target.to_dict() for key, target in self.targets.items()}
101
+
102
+ def _update_json(self) -> None:
103
+ with self._lock:
104
+ data = self._to_dict()
105
+ self._json.store(data)
106
+ self._mirror.changed(data)
107
+
108
+ def listen(self, listener: Listener) -> None:
109
+ """``listener(target_id, entry)`` is called for every target that changes from now on (``None``: deleted)."""
110
+ with self._lock:
111
+ self._mirror.listen(listener, self._to_dict())
112
+
113
+ def sync(self, get: Callable[[], dict[str, Any]], renamed: dict[str, str] | None = None) -> bool:
114
+ """Make the targets what ``get()`` returns (the content of ``targets.json``), keeping the targets that stay
115
+ - also one whose id changed from a key of ``renamed`` to its value. The listener is not called. Returns
116
+ whether something changed."""
117
+ with self._lock:
118
+ data, aside = self._json.split(get())
119
+ if data == self._to_dict() and aside == self._json.aside:
120
+ return False
121
+ for old, new in (renamed or {}).items():
122
+ if old in self.targets and new in data and new not in self.targets:
123
+ self.targets[new] = self.targets.pop(old)
124
+ for key in [key for key in self.targets if key not in data]:
125
+ del self.targets[key]
126
+ for key, entry in data.items():
127
+ target = self.targets.get(key)
128
+ try:
129
+ if (
130
+ target is not None
131
+ and target.target_type is not None
132
+ and target.target_type.name == entry.get("target-type")
133
+ ):
134
+ target.load(entry)
135
+ else:
136
+ self.targets[key] = load_target_factory(
137
+ entry, self.root_folder, self._update_json, self._logger
138
+ )
139
+ except ValueError as e:
140
+ self._logger.warning("sync - target '%s' set aside: %s", key, e)
141
+ self.targets.pop(key, None)
142
+ aside[key] = entry
143
+ self._remember_id(key)
144
+ data = self._to_dict()
145
+ self._json.store(data, aside)
146
+ self._mirror.replaced(data)
147
+ return True
148
+
149
+ def all(self) -> list[Target]:
150
+ """Snapshot of all targets."""
151
+ with self._lock:
152
+ return list(self.targets.values())
153
+
154
+ def albums(self) -> list[TargetAlbum]:
155
+ return [target for target in self.all() if isinstance(target, TargetAlbum)]
156
+
157
+ def radios(self) -> list[TargetRadio]:
158
+ return [target for target in self.all() if isinstance(target, TargetRadio)]
159
+
160
+ def spotify_entries(self) -> list[TargetSpotify]:
161
+ return [target for target in self.all() if isinstance(target, TargetSpotify)]
162
+
163
+ def get_target(self, target_id: str) -> Target:
164
+ """:raises ValueError: for unknown ids."""
165
+ target = self.targets.get(target_id)
166
+ if target is None:
167
+ self._logger.warning("get_target - unknown target_id %s.", target_id)
168
+ raise ValueError(f"get_target - unknown target_id {target_id}.")
169
+ return target
170
+
171
+ def get_targets_of_type(self, target_type: TargetType) -> list[Target]:
172
+ return [target for target in self.all() if target.target_type == target_type]
173
+
174
+ def add_target(self, target: dict[str, Any] | None, create_id: bool = True) -> Target:
175
+ """Add a target (``create_id``: with a new id). :raises ValueError: if it is invalid or the id exists."""
176
+ if target is None:
177
+ self._logger.warning("add_target - target is None.")
178
+ raise ValueError("add_target - target is None.")
179
+ with self._lock:
180
+ if create_id:
181
+ target["target-id"] = self.id_source() if self.id_source else str(self._highest_id() + 1)
182
+ self._logger.info("add_target - target: %s.", target)
183
+ if str(target.get("target-id")) in self.targets or str(target.get("target-id")) in self._json.aside:
184
+ self._logger.warning("add_target - target_id %s exists already.", target["target-id"])
185
+ raise ValueError(f"add_target - target_id {target['target-id']} exists already.")
186
+ new_target = create_target_factory(target, self.root_folder, self._update_json, self._logger)
187
+ target_id = new_target.target_id
188
+ assert target_id is not None # noqa: S101 - set by the constructor
189
+ self._remember_id(target_id)
190
+ self.targets[target_id] = new_target
191
+ self._update_json()
192
+ return new_target
193
+
194
+ def delete_target(self, target_id: str | None) -> None:
195
+ if target_id is None:
196
+ self._logger.warning("delete_target - target_id is None.")
197
+ raise ValueError("delete_target - target_id is None.")
198
+ with self._lock:
199
+ if target_id not in self.targets:
200
+ self._logger.warning("delete_target - unknown target_id %s.", target_id)
201
+ raise ValueError(f"delete_target - unknown target_id {target_id}.")
202
+ self._remember_id(target_id) # e.g. the file does not exist yet - the id would be given again
203
+ del self.targets[target_id]
204
+ self._update_json()
205
+
206
+ def sanity_check(self) -> tuple[bool, str]:
207
+ errors = []
208
+ for key, target in list(self.targets.items()):
209
+ if key != target.target_id:
210
+ message = f"sanity_check - key '{key}' != target.target_id '{target.target_id}'"
211
+ self._logger.warning(message)
212
+ errors.append(message)
213
+ passed, error = target.sanity_check()
214
+ if not passed:
215
+ errors.append(error)
216
+ return not errors, "\n".join(errors)
217
+
218
+ def to_text(self) -> str:
219
+ return "\n".join(f"key: {key}, target: {target.to_text()}" for key, target in list(self.targets.items()))
220
+
221
+ def _highest_id(self) -> int:
222
+ """The highest id given so far (0: none) - stored or, if higher, of the existing targets."""
223
+ keys = [*self.targets, *self._json.aside] # a target set aside keeps its id, too
224
+ return max([self._read_last_id(), *(int(key) for key in keys if _is_number(key))])
225
+
226
+ def _remember_id(self, target_id: str) -> None:
227
+ """Raise the stored last id to ``target_id`` - before the targets json is written: a crash leaves a gap,
228
+ never an id that is given twice."""
229
+ if _is_number(target_id) and int(target_id) > self._read_last_id():
230
+ self._write_last_id(int(target_id))
231
+
232
+ def _read_last_id(self) -> int:
233
+ try:
234
+ with open(self._last_id_file, encoding="utf-8") as f:
235
+ last_id = json.load(f)["last-id"]
236
+ except FileNotFoundError:
237
+ return 0
238
+ except (OSError, ValueError, KeyError, TypeError) as e:
239
+ self._logger.warning(
240
+ "cannot read '%s' (%s) - new ids follow the highest existing id.", self._last_id_file, e
241
+ )
242
+ return 0
243
+ if not isinstance(last_id, int) or isinstance(last_id, bool):
244
+ self._logger.warning(
245
+ "'%s': last-id must be a number - new ids follow the highest existing id.", self._last_id_file
246
+ )
247
+ return 0
248
+ return last_id
249
+
250
+ def _write_last_id(self, last_id: int) -> None:
251
+ temp = self._last_id_file + ".tmp"
252
+ with open(temp, "w", encoding="utf-8") as f:
253
+ json.dump({"last-id": last_id}, f)
254
+ f.flush()
255
+ os.fsync(f.fileno())
256
+ os.replace(temp, self._last_id_file)
@@ -0,0 +1,68 @@
1
+ """Start value and maximum of the volumes, changed at runtime (``volume.json`` next to ``tags.json``)."""
2
+
3
+ import logging
4
+ import os
5
+ import threading
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from tinyjukebox.core.logging import get_child
10
+ from tinyjukebox.data.common import Listener
11
+ from tinyjukebox.data.jsondata import JsonData
12
+ from tinyjukebox.schema.volume import schema
13
+
14
+ __all__ = ["VolumeLimits"]
15
+
16
+
17
+ class VolumeLimits:
18
+ """``{"speaker": {"default-volume": 50, "max-volume": 80}, "headphones": {...}}`` - a profile that has never been
19
+ changed is missing: the config applies to it. A missing or broken file is not fatal (the config applies)."""
20
+
21
+ def __init__(self, filename: str, logger: logging.Logger) -> None:
22
+ self._logger = get_child(logger, type(self).__name__)
23
+ self._lock = threading.Lock()
24
+ self._listener: Listener | None = None
25
+ backup = filename + "_"
26
+ if not os.path.exists(filename) and not os.path.exists(backup):
27
+ self._logger.info("__init__ - no volume file yet - the config applies until a limit is changed.")
28
+ self._create(filename)
29
+ try:
30
+ self._json = JsonData(filename, backup, schema, self._logger)
31
+ except FileNotFoundError:
32
+ self._logger.warning("__init__ - no usable volume file - the config applies.")
33
+ self._create(filename)
34
+ self._json = JsonData(filename, backup, schema, self._logger)
35
+
36
+ @staticmethod
37
+ def _create(filename: str) -> None:
38
+ with open(filename, "w", encoding="utf-8") as outfile:
39
+ outfile.write("{}")
40
+
41
+ def apply(self, profile: str, config: Mapping[str, Any]) -> dict[str, Any]:
42
+ """``config`` (keys ``default-volume``, ``max-volume``, ...) with the stored limits of ``profile``."""
43
+ with self._lock:
44
+ return {**config, **self._json.data.get(profile, {})}
45
+
46
+ def store(self, profile: str, default_volume: float, max_volume: float) -> None:
47
+ with self._lock:
48
+ data = dict(self._json.data)
49
+ data[profile] = {"default-volume": default_volume, "max-volume": max_volume}
50
+ self._json.store(data)
51
+ if self._listener is not None:
52
+ self._listener(profile, data[profile])
53
+
54
+ def listen(self, listener: Listener) -> None:
55
+ """``listener(profile, limits)`` is called whenever the limits of a profile are changed."""
56
+ self._listener = listener
57
+
58
+ def all(self) -> dict[str, Any]:
59
+ with self._lock:
60
+ return dict(self._json.data)
61
+
62
+ def sync(self, data: dict[str, Any]) -> bool:
63
+ """Make the stored limits ``data``; they apply from the next start. The listener is not called."""
64
+ with self._lock:
65
+ if data == self._json.data:
66
+ return False
67
+ self._json.store(dict(data))
68
+ return True
@@ -0,0 +1,180 @@
1
+ """Starting with one folder (``--data DIR``, docs/developer/configuration.md, section 1): library and box without a
2
+ config file of their own and without a users file made by hand - for the Docker image and the package from PyPI.
3
+
4
+ Everything lies below the folder, in the folders library and box use anyway. A file ``DIR/config.yaml``, if there is
5
+ one, changes single values. What the first start needs comes from environment variables, and counts at the first
6
+ start only; afterwards the folder is the truth, and passwords are changed on the page. A password can also come from
7
+ a file: ``TINYJUKEBOX_ADMIN_PASSWORD_FILE`` names it (a secret of Docker), and the variable itself stays empty.
8
+ """
9
+
10
+ import copy
11
+ import getpass
12
+ import logging
13
+ import os
14
+ import secrets
15
+ import sys
16
+ from typing import Any
17
+
18
+ from tinyjukebox.core.config import read_config
19
+ from tinyjukebox.web.auth import MIN_PASSWORD_LENGTH, UsersFile, hash_password
20
+
21
+ __all__ = [
22
+ "ADMIN_VARIABLE",
23
+ "BOOTSTRAP_VARIABLE",
24
+ "LIBRARY_VARIABLE",
25
+ "SLOTS_VARIABLE",
26
+ "box_config",
27
+ "first_admin",
28
+ "library_config",
29
+ "link_library",
30
+ ]
31
+
32
+ ADMIN_VARIABLE = "TINYJUKEBOX_ADMIN_PASSWORD" # the password of the user admin of the page, at the first start
33
+ BOOTSTRAP_VARIABLE = "TINYJUKEBOX_BOOTSTRAP_PASSWORD" # a library: the one new boxes prove; a box: the one it proves
34
+ LIBRARY_VARIABLE = "TINYJUKEBOX_LIBRARY" # a box: the address of its library, at the first start
35
+ SLOTS_VARIABLE = "TINYJUKEBOX_SLOTS" # a library: the folder with the slot it brings along (the Docker image)
36
+ REPOSITORY = "tgd1975/tinyjukebox" # where a library finds new versions for its boxes
37
+ PLATFORM = "aarch64-debian-trixie" # the platform of the boxes
38
+
39
+
40
+ def _secret(variable: str) -> str:
41
+ """The value of ``variable`` - or the content of the file that ``<variable>_FILE`` names, without the line end."""
42
+ filename = os.environ.get(variable + "_FILE", "")
43
+ if filename:
44
+ try:
45
+ with open(filename, encoding="utf-8") as infile:
46
+ return infile.read().rstrip("\r\n")
47
+ except OSError as error:
48
+ raise SystemExit(f"{variable}_FILE: {filename} cannot be read ({error.strerror or error})") from None
49
+ return os.environ.get(variable, "")
50
+
51
+
52
+ def _merged(base: dict[str, Any], change: dict[str, Any]) -> dict[str, Any]:
53
+ result = copy.deepcopy(base)
54
+ for key, value in change.items():
55
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
56
+ result[key] = _merged(result[key], value)
57
+ else:
58
+ result[key] = value
59
+ return result
60
+
61
+
62
+ def _with_changes(folder: str, config: dict[str, Any]) -> dict[str, Any]:
63
+ changes = os.path.join(folder, "config.yaml")
64
+ return _merged(config, read_config(changes)) if os.path.isfile(changes) else config
65
+
66
+
67
+ def _logger(folder: str, name: str) -> dict[str, Any]:
68
+ return {
69
+ "log-level": "INFO",
70
+ "log-file": os.path.join(folder, "log", f"{name}.log"),
71
+ "log-rotation": {"maxbytes": 1048576, "backupcount": 4},
72
+ }
73
+
74
+
75
+ def _rest_api(folder: str, port: int, name: str) -> dict[str, Any]:
76
+ return {
77
+ "port": port,
78
+ "certificate": os.path.join(folder, "ssl", f"{name}.crt"),
79
+ "private-key": os.path.join(folder, "ssl", f"{name}.key"),
80
+ "users-file": os.path.join(folder, "ssl", "users.yaml"),
81
+ }
82
+
83
+
84
+ def _folders(folder: str, *names: str) -> None:
85
+ for name in names:
86
+ os.makedirs(os.path.join(folder, name), exist_ok=True)
87
+ os.chmod(os.path.join(folder, "ssl"), 0o700) # private key, password hashes
88
+
89
+
90
+ def library_config(folder: str) -> dict[str, Any]:
91
+ """The config of a library below ``folder``."""
92
+ folder = os.path.abspath(os.path.expanduser(folder))
93
+ _folders(folder, "data", "albums", "images", "ssl", "log")
94
+ library: dict[str, Any] = {
95
+ "data-folder": os.path.join(folder, "data"),
96
+ "albums-folder": os.path.join(folder, "albums"),
97
+ "images-folder": os.path.join(folder, "images"),
98
+ "backups-folder": os.path.join(folder, "backups"),
99
+ "playlist-filename": "playlist.m3u",
100
+ "platform": PLATFORM,
101
+ "releases": {"repository": REPOSITORY},
102
+ "rest-api": _rest_api(folder, 8866, "library"),
103
+ }
104
+ if _secret(BOOTSTRAP_VARIABLE):
105
+ # taken over at the first start and whenever it names another one (library.md, section 4.14)
106
+ library["bootstrap-password"] = _secret(BOOTSTRAP_VARIABLE)
107
+ if os.environ.get(SLOTS_VARIABLE):
108
+ library["slots-folder"] = os.environ[SLOTS_VARIABLE]
109
+ return _with_changes(folder, {"logger": _logger(folder, "library"), "library": library})
110
+
111
+
112
+ def box_config(folder: str) -> dict[str, Any]:
113
+ """The config of a box without hardware below ``folder``, as ``deploy/config/tinyjukebox-sim.yaml`` has it."""
114
+ folder = os.path.abspath(os.path.expanduser(folder))
115
+ _folders(folder, "albums", "json", "ssl", "log")
116
+ json_files = {}
117
+ for key, name in (("tags", "tags"), ("targets", "targets"), ("activities", "activity")):
118
+ json_files[key] = os.path.join(folder, "json", f"{name}.json")
119
+ if not os.path.exists(json_files[key]):
120
+ with open(json_files[key], "w", encoding="utf-8") as outfile:
121
+ outfile.write("{}\n")
122
+ box = {
123
+ "albums-folder": os.path.join(folder, "albums"),
124
+ "playlist-filename": "playlist.m3u",
125
+ "volume": {"max-volume": 100, "default-volume": 40, "volume-step": 5},
126
+ "albumplayer": {"rewind-max-time": 10, "update-schedule": 5, "pause-between-tracks": 0},
127
+ "json": json_files,
128
+ "rest-api": _rest_api(folder, 8443, "tinyjukebox"),
129
+ }
130
+ return _with_changes(folder, {"logger": _logger(folder, "tinyjukebox"), "tinyjukebox": box})
131
+
132
+
133
+ def link_library(folder: str, logger: logging.Logger) -> None:
134
+ """A box at its first start: the library of ``TINYJUKEBOX_LIBRARY`` with its bootstrap password - as if they had
135
+ been entered on the page (library.md, section 4.2). Later the page changes them."""
136
+ address = os.environ.get(LIBRARY_VARIABLE, "").strip().rstrip("/")
137
+ identity = os.path.join(os.path.abspath(os.path.expanduser(folder)), "identity")
138
+ if not address or os.path.exists(os.path.join(identity, "library")):
139
+ return
140
+ os.makedirs(identity, mode=0o700, exist_ok=True)
141
+ with open(os.path.join(identity, "library"), "w", encoding="utf-8") as outfile:
142
+ outfile.write(address + "\n")
143
+ password = _secret(BOOTSTRAP_VARIABLE)
144
+ if password:
145
+ descriptor = os.open(os.path.join(identity, "bootstrap"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
146
+ with os.fdopen(descriptor, "w", encoding="utf-8") as outfile:
147
+ outfile.write(password + "\n")
148
+ logger.info("linked to the library %s at the first start", address)
149
+
150
+
151
+ def first_admin(users_file: str, logger: logging.Logger) -> None:
152
+ """The user ``admin`` of the page, if the page has no user yet: its password from ``TINYJUKEBOX_ADMIN_PASSWORD``
153
+ (or the file of ``TINYJUKEBOX_ADMIN_PASSWORD_FILE``), asked on the terminal, or - without a terminal - generated
154
+ and printed once on the error output (``docker logs``), never into the log file."""
155
+ users = UsersFile.read(users_file)
156
+ if users:
157
+ return
158
+ password = _secret(ADMIN_VARIABLE)
159
+ generated = False
160
+ if not password and sys.stdin.isatty():
161
+ while True:
162
+ password = getpass.getpass(f"password of the user admin of the page (at least {MIN_PASSWORD_LENGTH}): ")
163
+ if len(password) >= MIN_PASSWORD_LENGTH and getpass.getpass("again: ") == password:
164
+ break
165
+ print("too short, or the two do not match", file=sys.stderr)
166
+ if not password:
167
+ password = secrets.token_urlsafe(12)
168
+ generated = True
169
+ if len(password) < MIN_PASSWORD_LENGTH:
170
+ raise SystemExit(f"{ADMIN_VARIABLE}: the password needs at least {MIN_PASSWORD_LENGTH} characters")
171
+ UsersFile.write(users_file, {"admin": {"password-hash": hash_password(password), "role": "admin"}})
172
+ if generated:
173
+ print(f"the page has the user admin with the password {password} - change it on the page (tab System)",
174
+ file=sys.stderr) # fmt: skip
175
+ logger.warning(
176
+ "the page has the user admin with a generated password: it was printed once on the error output "
177
+ "(docker logs) - change it on the page (tab System)"
178
+ )
179
+ else:
180
+ logger.info("the page has the user admin")
@@ -0,0 +1 @@
1
+ """Device drivers of the box (RFID reader, buttons, RGB LED) and the DriverManager."""
@@ -0,0 +1 @@
1
+ """Base classes and helpers for drivers."""
@@ -0,0 +1,115 @@
1
+ """Base class of all drivers."""
2
+
3
+ import logging
4
+ from abc import ABCMeta, abstractmethod
5
+ from collections.abc import Mapping
6
+ from typing import Any, ClassVar
7
+
8
+ import tinyjukebox
9
+ from tinyjukebox.core.microservice import AbstractMicroservice
10
+ from tinyjukebox.core.pubsub import PubSubClient
11
+ from tinyjukebox.core.ui import Command
12
+
13
+ __all__ = ["ADriver"]
14
+
15
+
16
+ class ADriver(AbstractMicroservice, metaclass=ABCMeta):
17
+ """Base class of all drivers: a component with the config section ``driver``.
18
+
19
+ * applies the common driver config entries ``type`` and ``name``; topics and payloads are defined in
20
+ :mod:`tinyjukebox.messages`,
21
+ * start/stop via :meth:`_driver_start` / :meth:`_driver_stop`,
22
+ * drivers are created and started by the :class:`~tinyjukebox.devices.drivermanager.drivermanager.DriverManager`
23
+ (config section ``drivers``); they use its pubsub client and logger and have no command line of their own.
24
+
25
+ Driver specific console commands are added to ``self._ui_commands`` (or via :meth:`_add_ui_command`) within
26
+ ``__init__``; :attr:`driver_ui_commands` provides them to the DriverManager.
27
+ """
28
+
29
+ _version: ClassVar[str] = tinyjukebox.__version__
30
+
31
+ def __init__(
32
+ self,
33
+ config: Mapping[str, Any],
34
+ pubsub_client: PubSubClient,
35
+ logger: logging.Logger,
36
+ logger_name: str | None = None,
37
+ ) -> None:
38
+ super().__init__(config, "driver", pubsub_client, logger, logger_name or type(self).__name__, no_gui=True)
39
+
40
+ self._type = type(self).__name__
41
+ if self._type.upper() != self._config["type"].upper():
42
+ message = f"Type of class ({self._type.upper()}) is different than type of config "
43
+ message += f"({self._config['type'].upper()})."
44
+ self._logger.error("ADriver.__init__ - %s", message)
45
+ raise TypeError(message)
46
+
47
+ self._name: str = self._config.get("name", self._type)
48
+ self._logger = self._logger.getChild(self._name)
49
+
50
+ self._base_ui_commands = frozenset(self._ui_commands)
51
+
52
+ # --- public -------------------------------------------------------------------------------------------------
53
+
54
+ @property
55
+ def name(self) -> str:
56
+ return self._name
57
+
58
+ @property
59
+ def driver_type(self) -> str:
60
+ return self._type
61
+
62
+ @property
63
+ def driver_ui_commands(self) -> dict[str, Command]:
64
+ """The driver specific console commands (without the generic commands of the microservice)."""
65
+ return {name: command for name, command in self._ui_commands.items() if name not in self._base_ui_commands}
66
+
67
+ def get_name(self) -> str:
68
+ return self._name
69
+
70
+ def get_short_info(self) -> str:
71
+ return f"[{self._name}] type:{self._type} is_stopped:{self.is_stopped}"
72
+
73
+ def state(self) -> dict[str, Any]:
74
+ """What the driver knows about its hardware right now - the technical view of the web page shows it.
75
+ ``type`` is the one of the config (``input``, ``rgbled``, ...); drivers add theirs with
76
+ :meth:`_driver_state`."""
77
+ return {
78
+ "name": self._name,
79
+ "type": str(self._config["type"]).lower(),
80
+ "running": bool(self.is_started),
81
+ **self._driver_state(),
82
+ }
83
+
84
+ # --- helpers for drivers ------------------------------------------------------------------------------------
85
+
86
+ def _publish_value(self, topic: str, value: Any) -> None:
87
+ """Publish ``value`` to ``topic`` (see :mod:`tinyjukebox.messages`)."""
88
+ self._pubsub_client.publish(topic, value)
89
+ self._logger.info("ADriver._publish_value - %s: %s", topic, value)
90
+
91
+ # --- life cycle ---------------------------------------------------------------------------------------------
92
+
93
+ def _start(self) -> None:
94
+ self._driver_start()
95
+ self._logger.warning("Driver '%s.%s' started", self._type, self._name)
96
+
97
+ def _stop(self) -> None:
98
+ self._driver_stop()
99
+ self._logger.warning("Driver '%s.%s' stopped", self._type, self._name)
100
+
101
+ @abstractmethod
102
+ def _driver_start(self) -> None:
103
+ """Set up the hardware, subscribe to topics, start threads."""
104
+
105
+ @abstractmethod
106
+ def _driver_stop(self) -> None:
107
+ """Reverse of :meth:`_driver_start`."""
108
+
109
+ def _driver_state(self) -> dict[str, Any]:
110
+ """The driver specific part of :meth:`state` - called by any thread, also while the driver is stopped."""
111
+ return {}
112
+
113
+ @classmethod
114
+ def _get_description(cls) -> str:
115
+ return f"Device driver '{cls.__name__}'."
@@ -0,0 +1,47 @@
1
+ """Mixin for drivers that react to edges on GPIO pins."""
2
+
3
+ import logging
4
+ from collections.abc import Callable, Mapping
5
+ from typing import Any, NamedTuple
6
+
7
+ from RPi import GPIO
8
+
9
+ __all__ = ["AEvents", "EdgeCallback"]
10
+
11
+ EdgeCallback = Callable[[int], None]
12
+
13
+
14
+ class _Event(NamedTuple):
15
+ callback: EdgeCallback
16
+ bounce_time: int
17
+
18
+
19
+ class AEvents:
20
+ """Mixin for drivers that must react to changing pin states (``GPIO.setmode(GPIO.BCM)``).
21
+
22
+ Events are registered for both edges. The callback receives the channel and has to read the pin state itself to
23
+ distinguish rising and falling edges. Callbacks run in the callback thread of the GPIO library.
24
+ """
25
+
26
+ def __init__(self, config: Mapping[str, Any], logger: logging.Logger) -> None:
27
+ self._events: dict[int, _Event] = {}
28
+ self._logger_aevents = logger
29
+ GPIO.setmode(GPIO.BCM)
30
+
31
+ def _add_event(self, pin: int, func: EdgeCallback, bounce_time: int = 50) -> None:
32
+ """Remember an edge callback for ``pin``; it becomes active with :meth:`_register_events`."""
33
+ self._logger_aevents.info("AEvents._add_event - pin:%s, func:%s, bounce:%s.", pin, func, bounce_time)
34
+ self._events[int(pin)] = _Event(func, bounce_time)
35
+
36
+ def _register_events(self) -> None:
37
+ """Activate all edge callbacks - usually in ``_driver_start``."""
38
+ GPIO.setmode(GPIO.BCM)
39
+ for pin, event in self._events.items():
40
+ self._logger_aevents.debug("AEvents._register_events - pin:%s, bounce_time:%s", pin, event.bounce_time)
41
+ GPIO.add_event_detect(pin, GPIO.BOTH, callback=event.callback, bouncetime=event.bounce_time)
42
+
43
+ def _unregister_events(self) -> None:
44
+ """Deactivate all edge callbacks - usually in ``_driver_stop``."""
45
+ for pin in self._events:
46
+ self._logger_aevents.debug("AEvents._unregister_events - pin:%s", pin)
47
+ GPIO.remove_event_detect(pin)