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,568 @@
1
+ """The one Soloist of the box, with its sound (docs/developer/architecture.md, sections 2.5 and 4.1).
2
+
3
+ It runs while an entry of Spotify plays because of a tag or a test, and - with the speaker switched on - while no tag
4
+ is on the box: then the Spotify app of the account of the box can play on it. A tag that plays no Spotify ends it, so
5
+ that the app does not offer a box that would not listen. Only one Soloist runs at a time: they share the data
6
+ directory with the login. While "Connect" runs ``soloist --pair`` with it, none is started at all.
7
+
8
+ What the buttons, the headset and the page ask of the speaker is done in a thread of its own, one after the other:
9
+ ``soloist ctl`` takes a moment and may hang for seconds, and the dispatch thread of the messages never waits
10
+ (section 10).
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import queue
16
+ import shutil
17
+ import threading
18
+ import time
19
+ from collections.abc import Callable
20
+ from typing import Any, Protocol
21
+
22
+ from tinyjukebox.albumplayer.simpleplayer import AudioOutput
23
+ from tinyjukebox.core import unexpected
24
+ from tinyjukebox.core.logging import get_child
25
+ from tinyjukebox.messages import Button
26
+ from tinyjukebox.spotify.account import EXPIRED, Busy, SpotifyAccount, Unavailable
27
+ from tinyjukebox.spotify.soloist import Soloist
28
+ from tinyjukebox.spotify.sound import PipeReader, PipeWire, SoundServer
29
+
30
+ __all__ = ["SPEAKER_PAUSE_LIMIT", "Listener", "SpeakerPlayback", "SpotifyEngine"]
31
+
32
+ SPEAKER_PAUSE_LIMIT = 600.0
33
+ """Seconds that what the phone played may be paused before the box is free again (LED green)."""
34
+ CACHE_LIMIT = 300 * 2**20
35
+ """Bytes of the cache of Soloist after which the speaker ends Soloist while nothing plays, to empty it."""
36
+ IDLE_SECONDS = 5.0
37
+ """Seconds without a command after which the thread of the commands ends - the next command starts it again."""
38
+
39
+
40
+ class Listener(Protocol):
41
+ """The playback of an entry that uses Soloist (:class:`~tinyjukebox.albumplayer.spotifyplayer.SpotifyPlayer`)."""
42
+
43
+ def event(self, event: dict[str, Any]) -> None: ...
44
+
45
+ def exited(self, code: int) -> None:
46
+ """Soloist ended by itself (``code``: its exit code)."""
47
+ ...
48
+
49
+ def suspended(self) -> None:
50
+ """Soloist was ended by the box - for "Connect", or because the box ends: the playback waits until it may
51
+ start Soloist again. No failure."""
52
+ ...
53
+
54
+ def output_lost(self) -> None: ...
55
+
56
+
57
+ SoloistFactory = Callable[[SpotifyAccount, SoundServer], Soloist]
58
+ SoundFactory = Callable[[], SoundServer]
59
+
60
+
61
+ class _Commands:
62
+ """Runs functions one after the other in a thread of its own - started when there is something to do, it ends
63
+ when it has had nothing to do for ``idle`` seconds."""
64
+
65
+ def __init__(self, logger: logging.Logger, name: str, idle: float = IDLE_SECONDS) -> None:
66
+ self._logger = logger
67
+ self._name = name
68
+ self._idle = idle
69
+ self._lock = threading.Lock()
70
+ self._queue: queue.Queue[Callable[[], None]] = queue.Queue()
71
+ self._thread: threading.Thread | None = None
72
+
73
+ def put(self, function: Callable[[], None]) -> None:
74
+ """Queue ``function`` - returns at once."""
75
+ with self._lock:
76
+ self._queue.put(function)
77
+ if self._thread is None:
78
+ self._thread = threading.Thread(target=self._run, name=self._name, daemon=True)
79
+ self._thread.start()
80
+
81
+ def wait_until_done(self, timeout: float | None = None) -> bool:
82
+ """Wait until what was queued before has been done (tests)."""
83
+ done = threading.Event()
84
+ self.put(done.set)
85
+ return done.wait(timeout)
86
+
87
+ def _run(self) -> None:
88
+ while True:
89
+ try:
90
+ function = self._queue.get(timeout=self._idle)
91
+ except queue.Empty:
92
+ with self._lock:
93
+ if self._queue.empty():
94
+ self._thread = None
95
+ return
96
+ continue
97
+ try:
98
+ function()
99
+ except Exception as error:
100
+ self._logger.exception("a command of the Spotify speaker failed")
101
+ unexpected.report(error, f"thread {self._name}")
102
+
103
+
104
+ class SpotifyEngine:
105
+ """Starts and ends Soloist and its sound for whoever needs it: the playback of an entry (:meth:`open`,
106
+ :meth:`close`) or the speaker (:meth:`idle`, :meth:`taken`). "Connect" of the account suspends it, :meth:`stop`
107
+ ends it for good."""
108
+
109
+ def __init__(
110
+ self,
111
+ account: SpotifyAccount,
112
+ output: AudioOutput,
113
+ logger: logging.Logger,
114
+ device_name: Callable[[], str],
115
+ soloist_factory: SoloistFactory | None = None,
116
+ sound_factory: SoundFactory | None = None,
117
+ clock: Callable[[], float] = time.monotonic,
118
+ speaker_pause_limit: float = SPEAKER_PAUSE_LIMIT,
119
+ ) -> None:
120
+ self.account = account
121
+ self._logger = get_child(logger, type(self).__name__)
122
+ self._device_name = device_name
123
+ self._soloist_factory = soloist_factory or self._create_soloist
124
+ self._sound_factory = sound_factory or (lambda: PipeWire(os.path.join(account.folder, "sound"), output, logger))
125
+ self._clock = clock
126
+ self._speaker_pause_limit = speaker_pause_limit
127
+ self._lock = threading.RLock()
128
+ self._sound_lock = threading.Lock() # what of the speaker is heard - never held while anything waits
129
+ self._commands = _Commands(self._logger, "Spotify.commands")
130
+ self._soloist: Soloist | None = None
131
+ self._sound: SoundServer | None = None
132
+ self._listener: Listener | None = None
133
+ self._box_idle = False
134
+ self._suspended = False # "Connect" runs soloist --pair with the data directory
135
+ self._ended = False # the box ends
136
+ self._heard = False # SPOTIFY_SPEAKER: what the phone plays is heard
137
+ self._losses = 0 # losses of the headset: an output change queued before one does not make the box heard
138
+ self._speaker_playing = False
139
+ self._speaker_paused_at: float | None = None
140
+ self._timer: threading.Timer | None = None
141
+ self.on_speaker_plays: Callable[[], None] = lambda: None
142
+ """Something plays from the Spotify app while no tag is on the box (STMOperation: SPOTIFY_SPEAKER)."""
143
+ self.on_speaker_ends: Callable[[], None] = lambda: None
144
+ """What the Spotify app played has ended, was paused for long or was taken to another device."""
145
+ self.on_speaker_paused: Callable[[bool], None] = lambda paused: None
146
+ """What the Spotify app plays on the box was paused (True) or continues (False) - the LED."""
147
+ self.speaker_info: dict[str, Any] | None = None
148
+ """What the phone plays on the box: title and state - for the tab "Now"."""
149
+ account.on_speaker = self._speaker_switched
150
+ account.before_connect = self._suspend
151
+ account.after_connect = self._resume
152
+
153
+ # --- Soloist -----------------------------------------------------------------------------------------------------
154
+
155
+ def _create_soloist(self, account: SpotifyAccount, sound: SoundServer) -> Soloist:
156
+ return Soloist(account.usable(), str(account.key), self._device_name(), account.data_dir, account.cache_dir,
157
+ self._logger, sound.env, sound.device) # fmt: skip
158
+
159
+ @property
160
+ def running(self) -> bool:
161
+ return self._soloist is not None
162
+
163
+ @property
164
+ def reader(self) -> PipeReader | None:
165
+ sound = self._sound
166
+ return sound.reader if sound is not None else None
167
+
168
+ def _busy(self) -> Busy | None:
169
+ """Why no Soloist may be started now - None: it may."""
170
+ if self._ended:
171
+ return Busy("the box is ending.")
172
+ if self._suspended:
173
+ return Busy("the box is being connected to an account of Spotify.")
174
+ return None
175
+
176
+ def _start(self) -> Soloist:
177
+ """Soloist and its sound - started if they do not run. :raises Busy, Unavailable, OSError:"""
178
+ with self._lock:
179
+ busy = self._busy()
180
+ if busy is not None:
181
+ raise busy
182
+ if self._soloist is not None and self._soloist.running:
183
+ return self._soloist
184
+ self._stop()
185
+ self.account.usable()
186
+ if not self.account.connected:
187
+ raise Unavailable("not_connected", "the box is not connected to an account of Spotify.")
188
+ sound = self._sound_factory()
189
+ sound.start()
190
+ try:
191
+ soloist = self._soloist_factory(self.account, sound)
192
+ soloist.on_event = self._event
193
+ soloist.on_exit = lambda code: self._exited(soloist, code)
194
+ sound.reader.on_output_lost = self._output_lost
195
+ # known before it starts: if it ends at once, _exited (waiting for the lock) sees that it was this one
196
+ self._sound, self._soloist = sound, soloist
197
+ soloist.start()
198
+ except Exception:
199
+ self._sound = self._soloist = None
200
+ sound.stop()
201
+ raise
202
+ return soloist
203
+
204
+ def _stop(self) -> None:
205
+ with self._lock:
206
+ soloist, sound = self._soloist, self._sound
207
+ self._soloist = self._sound = None
208
+ self._speaker_playing = False
209
+ self.speaker_info = None
210
+ self._cancel_timer()
211
+ if soloist is not None:
212
+ soloist.stop()
213
+ if sound is not None:
214
+ sound.stop()
215
+ if soloist is not None:
216
+ # Soloist does not keep to the size of its cache (open report no. 8); it shares the card with the albums
217
+ shutil.rmtree(self.account.cache_dir, ignore_errors=True)
218
+
219
+ def stop(self) -> None:
220
+ """End Soloist for good - the end of the box. A playback that still runs waits until it is stopped."""
221
+ with self._lock:
222
+ self._ended = True
223
+ listener, self._listener = self._listener, None
224
+ if listener is not None:
225
+ listener.suspended()
226
+ self._stop()
227
+
228
+ def _suspend(self) -> None:
229
+ """Before "Connect" or "Disconnect" (the account's ``before_connect``): Soloist ends, and none is started until
230
+ :meth:`_resume` - it would share the data directory with ``soloist --pair``. A playback waits meanwhile; what
231
+ the phone played on the box has ended."""
232
+ with self._lock:
233
+ self._suspended = True
234
+ listener, self._listener = self._listener, None
235
+ playing = self._speaker_playing
236
+ if listener is not None:
237
+ listener.suspended()
238
+ self._end_speaker_playback() # what the phone played on the box is paused
239
+ self._stop()
240
+ self._logger.info("Spotify: no Soloist until the login of the box has changed")
241
+ if playing:
242
+ self.on_speaker_ends()
243
+
244
+ def _resume(self) -> None:
245
+ """After "Connect" or "Disconnect" (the account's ``after_connect``): Soloist may run again - for the speaker at
246
+ once, if no tag is on the box. A playback that waits starts it with its next try."""
247
+ with self._lock:
248
+ self._suspended = False
249
+ idle = self._box_idle
250
+ if idle:
251
+ self.idle()
252
+
253
+ def _exited(self, soloist: Soloist, code: int) -> None:
254
+ """``soloist`` ended by itself."""
255
+ with self._lock:
256
+ if soloist is not self._soloist:
257
+ return # one that has been replaced already
258
+ self._soloist = None
259
+ sound, self._sound = self._sound, None
260
+ if sound is not None:
261
+ sound.stop()
262
+ if code == EXPIRED:
263
+ self.account.expired()
264
+ listener = self._listener
265
+ if listener is not None:
266
+ listener.exited(code)
267
+ elif self._box_idle:
268
+ self._speaker_ended()
269
+
270
+ def _output_lost(self) -> None:
271
+ """``aplay`` failed on the headset (the reader has muted itself): whatever plays pauses."""
272
+ listener = self._listener
273
+ if listener is not None:
274
+ listener.output_lost()
275
+ return
276
+ with self._sound_lock:
277
+ self._losses += 1
278
+ self.button(Button.PAUSE)
279
+
280
+ # --- the playback of an entry ------------------------------------------------------------------------------------
281
+
282
+ def open(self, listener: Listener) -> Soloist:
283
+ """Soloist for the playback of an entry - started if it does not run (blocks).
284
+ :raises Busy: "Connect" runs or the box ends - the playback tries again later. :raises Unavailable, OSError:"""
285
+ with self._lock:
286
+ busy = self._busy()
287
+ if busy is not None:
288
+ raise busy
289
+ self._listener = listener
290
+ self._end_speaker_playback()
291
+ try:
292
+ return self._start()
293
+ except Exception:
294
+ self._listener = None
295
+ raise
296
+
297
+ def close(self, listener: Listener) -> None:
298
+ """The playback of an entry has ended. Soloist keeps running for the speaker, if it is wanted."""
299
+ with self._lock:
300
+ if self._listener is not listener:
301
+ return
302
+ self._listener = None
303
+ keep = self._box_idle and self.account.speaker
304
+ if not keep:
305
+ self._stop()
306
+
307
+ # --- the speaker -------------------------------------------------------------------------------------------------
308
+
309
+ def idle(self) -> None:
310
+ """No tag is on the box (WAIT_FOR_TAG, IDLE_LONG): with the speaker switched on, Soloist runs - started in the
311
+ thread of the commands."""
312
+ with self._lock:
313
+ self._box_idle = True
314
+ wanted = self._speaker_wanted() and self._soloist is None
315
+ if wanted:
316
+ self._commands.put(self._start_speaker)
317
+
318
+ def _speaker_wanted(self) -> bool:
319
+ return self._box_idle and self._listener is None and self._busy() is None and self.account.speaker
320
+
321
+ def taken(self, spotify: bool) -> None:
322
+ """A tag was placed or a test started; ``spotify``: it plays an entry of Spotify, which uses the running
323
+ Soloist. Anything else ends Soloist."""
324
+ with self._lock:
325
+ self._box_idle = False
326
+ self._end_speaker_playback()
327
+ stop = not spotify and self._listener is None
328
+ if stop:
329
+ self._stop()
330
+
331
+ def _start_speaker(self) -> None:
332
+ """In the thread of the commands: Soloist for the speaker - if it is still wanted."""
333
+ with self._lock:
334
+ if not self._speaker_wanted():
335
+ return
336
+ try:
337
+ self._start()
338
+ except (Busy, Unavailable, OSError) as e:
339
+ self._logger.info("Spotify speaker not available: %s", e)
340
+ return
341
+ with self._lock:
342
+ if not self._box_idle or self._listener is not None:
343
+ return
344
+ self._logger.info("Spotify speaker: the box waits for the Spotify app")
345
+ if self._cache_too_large():
346
+ self._logger.info("Spotify speaker: the cache of Soloist is emptied")
347
+ self._stop()
348
+ self._commands.put(self._start_speaker)
349
+
350
+ def _cache_too_large(self) -> bool:
351
+ total = 0
352
+ for root, _, names in os.walk(self.account.cache_dir):
353
+ for name in names:
354
+ try:
355
+ total += os.path.getsize(os.path.join(root, name))
356
+ except OSError:
357
+ continue
358
+ return total > CACHE_LIMIT
359
+
360
+ def _speaker_switched(self, enabled: bool) -> None:
361
+ if enabled and self._box_idle:
362
+ self.idle()
363
+ elif not enabled and self._listener is None:
364
+ self._end_speaker_playback()
365
+ self._stop()
366
+ if self._box_idle:
367
+ self.on_speaker_ends()
368
+
369
+ def playback(self) -> "SpeakerPlayback":
370
+ """What the phone plays on the box, as the playback that STMOperation shows and steers."""
371
+ return SpeakerPlayback(self)
372
+
373
+ def listen(self) -> None:
374
+ """SPOTIFY_SPEAKER: what the phone plays is heard."""
375
+ with self._sound_lock:
376
+ self._heard = True
377
+ reader = self.reader
378
+ if reader is not None:
379
+ reader.sound()
380
+
381
+ def silence(self) -> None:
382
+ """SPOTIFY_SPEAKER is left: what the phone played is not heard any more - at once, and what is in the buffer of
383
+ ``aplay`` neither - and it is paused."""
384
+ with self._sound_lock:
385
+ self._heard = False
386
+ reader = self.reader
387
+ if reader is not None:
388
+ reader.muted = True
389
+ if reader is not None:
390
+ reader.silence()
391
+ self._end_speaker_playback()
392
+
393
+ def button(self, button: Button) -> None:
394
+ """A button of the box while the Spotify app plays on it: play/pause, "next" and "back" steer what the phone
395
+ started; the volume is the box's anyway. Returns at once: ``soloist ctl`` runs in the thread of the commands."""
396
+ command = {Button.PLAY: "play", Button.PAUSE: "pause", Button.NEXT: "next", Button.PREV: "prev"}.get(button)
397
+ if command is not None:
398
+ self._commands.put(lambda: self._speaker_command(command))
399
+
400
+ def speaker_output_lost(self, then: Callable[[], None] | None = None) -> None:
401
+ """The headset has gone while the phone plays on the box: muted at once - nothing reaches ``aplay`` any more,
402
+ whatever it plays on - and the phone's playback is paused in the thread of the commands, after what was queued
403
+ before; then ``then`` is called. Returns at once."""
404
+ with self._sound_lock:
405
+ self._losses += 1
406
+ reader = self.reader
407
+ if reader is not None:
408
+ reader.muted = True
409
+ self._commands.put(lambda: self._pause_for_output(then))
410
+
411
+ def _pause_for_output(self, then: Callable[[], None] | None) -> None:
412
+ try:
413
+ self._speaker_command("pause")
414
+ finally:
415
+ if then is not None:
416
+ then()
417
+
418
+ def speaker_output_changed(self) -> None:
419
+ """Another output while the phone plays on the box: the sound goes on there - another ``aplay``, in the thread
420
+ of the commands. A loss of the headset meanwhile keeps it muted. Returns at once."""
421
+ with self._sound_lock:
422
+ loss = self._losses
423
+ self._commands.put(lambda: self._output_changed(loss))
424
+
425
+ def _output_changed(self, loss: int) -> None:
426
+ reader = self.reader
427
+ if reader is None:
428
+ return
429
+ reader.restart_output()
430
+ with self._sound_lock:
431
+ if self._heard and self._losses == loss and reader is self.reader:
432
+ reader.sound()
433
+
434
+ def _speaker_command(self, command: str) -> None:
435
+ """``soloist ctl <command>`` for what the phone plays - only while it is heard (SPOTIFY_SPEAKER)."""
436
+ with self._lock:
437
+ soloist = self._soloist if self._listener is None else None
438
+ if soloist is None or not self._heard:
439
+ return
440
+ try:
441
+ soloist.ctl(command)
442
+ except Exception as e: # Soloist has ended meanwhile
443
+ self._logger.info("Spotify speaker: %s: %s", command, e)
444
+
445
+ def wait_until_done(self, timeout: float | None = None) -> bool:
446
+ """Wait until the commands queued before have been done (tests)."""
447
+ return self._commands.wait_until_done(timeout)
448
+
449
+ def _end_speaker_playback(self) -> None:
450
+ with self._lock:
451
+ playing, self._speaker_playing = self._speaker_playing, False
452
+ self.speaker_info = None
453
+ self._cancel_timer()
454
+ soloist = self._soloist if self._listener is None else None
455
+ if playing and soloist is not None and soloist.running:
456
+ try:
457
+ soloist.ctl("pause")
458
+ except Exception as e: # the phone has taken it away already, or Soloist has ended
459
+ self._logger.info("Spotify speaker: pause: %s", e)
460
+
461
+ def _cancel_timer(self) -> None:
462
+ timer, self._timer = self._timer, None
463
+ if timer is not None:
464
+ timer.cancel()
465
+
466
+ def _event(self, event: dict[str, Any]) -> None:
467
+ listener = self._listener
468
+ if listener is not None:
469
+ listener.event(event)
470
+ return
471
+ if not self._box_idle:
472
+ return
473
+ kind = event.get("type")
474
+ if kind in ("playback_state", "playback_changed"):
475
+ status = event.get("status")
476
+ if kind == "playback_state":
477
+ self._speaker_track(event.get("item"))
478
+ if status == "playing":
479
+ self._speaker_started()
480
+ elif status == "paused":
481
+ self._speaker_paused()
482
+ elif status == "idle":
483
+ self._speaker_ended()
484
+ elif kind == "track_changed":
485
+ self._speaker_track(event.get("item"))
486
+ elif kind in ("device_changed", "auth_state") and event.get("is_active") is False:
487
+ self._speaker_ended()
488
+
489
+ def _speaker_track(self, item: object) -> None:
490
+ if isinstance(item, dict):
491
+ identity = (item.get("decorations") or {}).get("identity") or {}
492
+ self.speaker_info = {**(self.speaker_info or {}), "title": identity.get("name"), "uri": item.get("uri")}
493
+
494
+ def _speaker_started(self) -> None:
495
+ with self._lock:
496
+ self._cancel_timer()
497
+ self._speaker_paused_at = None
498
+ started = not self._speaker_playing
499
+ self._speaker_playing = True
500
+ self.speaker_info = {**(self.speaker_info or {}), "state": "playing"}
501
+ if started:
502
+ self._logger.info("Spotify speaker: the Spotify app plays on the box")
503
+ self.on_speaker_plays()
504
+ else:
505
+ self.on_speaker_paused(False)
506
+
507
+ def _speaker_paused(self) -> None:
508
+ with self._lock:
509
+ if not self._speaker_playing:
510
+ return
511
+ self.speaker_info = {**(self.speaker_info or {}), "state": "paused"}
512
+ self._cancel_timer()
513
+ self._timer = threading.Timer(self._speaker_pause_limit, self._speaker_ended)
514
+ self._timer.daemon = True
515
+ self._timer.start()
516
+ self.on_speaker_paused(True)
517
+
518
+ def _speaker_ended(self) -> None:
519
+ with self._lock:
520
+ playing, self._speaker_playing = self._speaker_playing, False
521
+ self.speaker_info = None
522
+ self._cancel_timer()
523
+ if playing:
524
+ self._logger.info("Spotify speaker: the Spotify app does not play on the box any more")
525
+ self.on_speaker_ends()
526
+
527
+
528
+ class SpeakerPlayback:
529
+ """What the Spotify app plays on the box while no tag is on it - steered like an album by the buttons, the headset
530
+ and the page (:class:`~tinyjukebox.stm.stmoperation.AlbumControl`). Every command returns at once; ``soloist ctl``
531
+ runs in the thread of the commands of the engine."""
532
+
533
+ def __init__(self, engine: SpotifyEngine) -> None:
534
+ self._engine = engine
535
+
536
+ def start(self) -> None:
537
+ self._engine.listen()
538
+
539
+ def stop(self) -> None:
540
+ self._engine.silence()
541
+
542
+ def cmd_next_handler(self) -> None:
543
+ self._engine.button(Button.NEXT)
544
+
545
+ def cmd_prev_handler(self) -> None:
546
+ self._engine.button(Button.PREV)
547
+
548
+ def cmd_restart_album(self) -> None:
549
+ """What the phone plays has no beginning the box knows."""
550
+
551
+ def cmd_pause(self) -> None:
552
+ self._engine.button(Button.PAUSE)
553
+
554
+ def cmd_play(self) -> None:
555
+ self._engine.button(Button.PLAY)
556
+
557
+ def cmd_output_lost(self, then: Callable[[], None] | None = None) -> None:
558
+ """The headset has gone: the sound is muted at once, the phone's playback pauses; ``then`` is called once the
559
+ pause has been handled - in the thread of the commands, after what was queued before."""
560
+ self._engine.speaker_output_lost(then)
561
+
562
+ def cmd_output_changed(self) -> None:
563
+ self._engine.speaker_output_changed()
564
+
565
+ def get_info(self) -> dict[str, Any]:
566
+ info = self._engine.speaker_info or {}
567
+ return {"target_type": "speaker", "name": info.get("title"), "uri": info.get("uri"),
568
+ "state": info.get("state", "playing")} # fmt: skip
@@ -0,0 +1,65 @@
1
+ """Name and cover of an entry of Spotify, from Spotify's oEmbed address - it needs no account.
2
+
3
+ What it returns depends on the kind: an album, a playlist, a track and an artist come with their name and a square
4
+ cover. For a podcast (``show``) it returns its newest episode: that title is not taken, and its picture only if it is
5
+ square. An audiobook or an episode often returns nothing. Whatever fails leaves the name or the cover out - the entry
6
+ is then named by hand.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import urllib.parse
12
+ import urllib.request
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from tinyjukebox.data.target import spotify_kind, spotify_link
18
+ from tinyjukebox.images import MAX_IMAGE_SIZE, image_extension
19
+
20
+ __all__ = ["OEMBED", "Found", "Opener", "look_up"]
21
+
22
+ OEMBED = "https://open.spotify.com/oembed?url="
23
+ TIMEOUT = 10.0
24
+ MAX_ANSWER = 64 * 1024
25
+
26
+ Opener = Callable[..., Any]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Found:
31
+ """What Spotify says about an entry: ``name`` and ``image`` (jpeg, png or webp) or ``None``."""
32
+
33
+ name: str | None = None
34
+ image: bytes | None = None
35
+
36
+
37
+ def _read(opener: Opener, url: str, limit: int) -> bytes:
38
+ with opener(url, timeout=TIMEOUT) as response:
39
+ return response.read(limit + 1) # type: ignore[no-any-return]
40
+
41
+
42
+ def look_up(uri: str, logger: logging.Logger, opener: Opener = urllib.request.urlopen, image: bool = True) -> Found:
43
+ """Name and cover of the entry ``uri`` (``spotify:<kind>:<id>``); ``image``: fetch the cover as well."""
44
+ try:
45
+ raw = _read(opener, OEMBED + urllib.parse.quote(spotify_link(uri), safe=""), MAX_ANSWER)
46
+ answer = json.loads(raw[:MAX_ANSWER].decode("utf-8"))
47
+ except (OSError, ValueError) as e:
48
+ logger.info("no name and cover from Spotify for %s: %s", uri, e)
49
+ return Found()
50
+ if not isinstance(answer, dict):
51
+ return Found()
52
+ podcast = spotify_kind(uri) == "show"
53
+ title = answer.get("title")
54
+ name = title.strip()[:100] if isinstance(title, str) and title.strip() and not podcast else None
55
+ width, height = answer.get("thumbnail_width"), answer.get("thumbnail_height")
56
+ thumbnail = answer.get("thumbnail_url")
57
+ data = None
58
+ if image and isinstance(thumbnail, str) and thumbnail.startswith("https://") and (not podcast or width == height):
59
+ try:
60
+ data = _read(opener, thumbnail, MAX_IMAGE_SIZE)
61
+ except OSError as e:
62
+ logger.info("no cover from Spotify for %s: %s", uri, e)
63
+ if data is not None and (len(data) > MAX_IMAGE_SIZE or image_extension(data) is None):
64
+ data = None
65
+ return Found(name, data)