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,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)
|