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,391 @@
|
|
|
1
|
+
"""Internet radio: plays a stream with ffmpeg and aplay (see :mod:`~tinyjukebox.albumplayer.simpleplayer`) until it is
|
|
2
|
+
stopped - and connects again whenever the stream breaks off."""
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from tinyjukebox.albumplayer import simpleplayer
|
|
15
|
+
from tinyjukebox.core.logging import get_child
|
|
16
|
+
from tinyjukebox.data.target import check_stream_url
|
|
17
|
+
from tinyjukebox.diagnostics import diagnostic
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"PLAYLIST_EXTENSIONS",
|
|
21
|
+
"RETRY_DELAYS",
|
|
22
|
+
"RadioPlayer",
|
|
23
|
+
"RadioPlayerInterface",
|
|
24
|
+
"RadioState",
|
|
25
|
+
"StateHook",
|
|
26
|
+
"resolve_stream_url",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
PLAYLIST_EXTENSIONS = (".m3u", ".pls")
|
|
30
|
+
"""Urls with these endings are playlists that list the stream - ffmpeg cannot play them itself (HLS: ``.m3u8``
|
|
31
|
+
is played by ffmpeg directly)."""
|
|
32
|
+
MAX_PLAYLIST_SIZE = 64 * 1024
|
|
33
|
+
PLAYLIST_TIMEOUT = 10.0
|
|
34
|
+
RETRY_DELAYS = (1.0, 2.0, 5.0, 10.0, 30.0)
|
|
35
|
+
"""Seconds to wait before the 1st, 2nd, ... attempt to connect again - the last one is repeated."""
|
|
36
|
+
STABLE_PLAYBACK = 60.0
|
|
37
|
+
"""A stream that played this long counts as working again - the next retry starts with the first delay."""
|
|
38
|
+
STARTUP_WINDOW = 30.0
|
|
39
|
+
"""Seconds after the start of a connection in which breaks of the sound count as breaks of its start."""
|
|
40
|
+
DEFAULT_BUFFER = 0.5
|
|
41
|
+
"""Seconds of aplay's buffer if none is configured (aplay's own default): the sound is heard only when it is full."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ms(since: float | None, until: float | None) -> int | None:
|
|
45
|
+
return None if since is None or until is None else round((until - since) * 1000)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _reason(error: str) -> str:
|
|
49
|
+
"""Which part failed: the station or the network, the decoder, or the output (sound card, headset)."""
|
|
50
|
+
text = error.lower()
|
|
51
|
+
if text.startswith("cannot load playlist"):
|
|
52
|
+
return "playlist"
|
|
53
|
+
if text.startswith("cannot start"):
|
|
54
|
+
return "start"
|
|
55
|
+
if "aplay" in text or "alsa" in text or "audio open error" in text:
|
|
56
|
+
return "output"
|
|
57
|
+
if any(word in text for word in ("http", "connection", "timed out", "timeout", "network", "resolve", "refused",
|
|
58
|
+
"i/o error", "end of file", "server returned")): # fmt: skip
|
|
59
|
+
return "connection"
|
|
60
|
+
if text == "stream ended":
|
|
61
|
+
return "ended"
|
|
62
|
+
return "decoder"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Connection:
|
|
66
|
+
"""One connection to a stream, measured on a clock that does not jump (docs/developer/library.md, 4.22): what its
|
|
67
|
+
start took, phase by phase, and how often the sound broke off."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, attempt: int, began: float) -> None:
|
|
70
|
+
self.attempt = attempt
|
|
71
|
+
self.player_began = began # when the player was started: about when the tag was placed
|
|
72
|
+
self.began = time.monotonic()
|
|
73
|
+
self.resolved: float | None = None
|
|
74
|
+
self.kind = "direct"
|
|
75
|
+
self.started: float | None = None # ffmpeg and aplay run
|
|
76
|
+
self.output: float | None = None # the first audio came from ffmpeg
|
|
77
|
+
self.audible: float | None = None # aplay's buffer is full: it plays
|
|
78
|
+
self.underruns: list[tuple[float, float]] = [] # (when, ms)
|
|
79
|
+
self.reported = False
|
|
80
|
+
self.lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def first_output(self) -> None:
|
|
83
|
+
with self.lock:
|
|
84
|
+
if self.output is None:
|
|
85
|
+
self.output = time.monotonic()
|
|
86
|
+
|
|
87
|
+
def heard(self) -> None:
|
|
88
|
+
with self.lock:
|
|
89
|
+
if self.audible is None:
|
|
90
|
+
self.audible = time.monotonic()
|
|
91
|
+
|
|
92
|
+
def underrun(self, ms: float) -> None:
|
|
93
|
+
with self.lock:
|
|
94
|
+
self.underruns.append((time.monotonic(), ms))
|
|
95
|
+
|
|
96
|
+
def startup(self, buffer: float) -> dict[str, Any]:
|
|
97
|
+
"""What the start of this connection took, in ms from its beginning, and its breaks in the first seconds."""
|
|
98
|
+
with self.lock:
|
|
99
|
+
window = None if self.started is None else self.started + STARTUP_WINDOW
|
|
100
|
+
initial = [ms for when, ms in self.underruns if window is not None and when <= window]
|
|
101
|
+
return {
|
|
102
|
+
"attempt": self.attempt, "kind": self.kind, "resolve_ms": _ms(self.began, self.resolved),
|
|
103
|
+
"pipeline_ms": _ms(self.began, self.started), "first_audio_ms": _ms(self.began, self.output),
|
|
104
|
+
"audible_ms": _ms(self.began, self.audible), "buffer_ms": round(buffer * 1000),
|
|
105
|
+
"since_player_start_ms": _ms(self.player_began, self.began),
|
|
106
|
+
"underruns": len(initial), "underrun_ms": round(sum(initial)),
|
|
107
|
+
} # fmt: skip
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RadioState(Enum):
|
|
111
|
+
CONNECTING = "connecting" # resolving the playlist, starting ffmpeg and aplay, filling aplay's buffer: no sound yet
|
|
112
|
+
PLAYING = "playing" # the sound is heard
|
|
113
|
+
RETRYING = "retrying" # the stream failed or ended - waiting to connect again
|
|
114
|
+
STOPPED = "stopped"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
StateHook = Callable[[RadioState], object]
|
|
118
|
+
"""Called with every new state (from the player thread) - must not block."""
|
|
119
|
+
|
|
120
|
+
Opener = Callable[..., Any]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_stream_url(url: str, timeout: float = PLAYLIST_TIMEOUT, opener: Opener = urllib.request.urlopen) -> str:
|
|
124
|
+
"""The url of the stream: ``url`` itself or - for m3u/pls playlists - the first http(s) entry of the playlist
|
|
125
|
+
(relative entries are resolved against ``url``). HLS playlists (``#EXT-X-`` tags) are left to ffmpeg.
|
|
126
|
+
|
|
127
|
+
:raises OSError: if the playlist cannot be loaded. :raises ValueError: if it lists no stream.
|
|
128
|
+
"""
|
|
129
|
+
if not urllib.parse.urlsplit(url).path.lower().endswith(PLAYLIST_EXTENSIONS):
|
|
130
|
+
return url
|
|
131
|
+
with opener(url, timeout=timeout) as response:
|
|
132
|
+
content = response.read(MAX_PLAYLIST_SIZE)
|
|
133
|
+
text = content.decode("utf-8-sig", errors="replace")
|
|
134
|
+
if "#EXT-X-" in text:
|
|
135
|
+
return url
|
|
136
|
+
for line in text.splitlines():
|
|
137
|
+
entry = line.strip()
|
|
138
|
+
if not entry or entry.startswith(("#", "[")):
|
|
139
|
+
continue
|
|
140
|
+
key, separator, value = entry.partition("=")
|
|
141
|
+
if separator and key.lower().startswith("file"): # pls: File1=http://...
|
|
142
|
+
entry = value.strip()
|
|
143
|
+
elif separator and "://" not in key:
|
|
144
|
+
continue # other pls keys (Title1=..., NumberOfEntries=...)
|
|
145
|
+
try:
|
|
146
|
+
return check_stream_url(urllib.parse.urljoin(url, entry))
|
|
147
|
+
except ValueError:
|
|
148
|
+
continue
|
|
149
|
+
raise ValueError(f"playlist '{url}' lists no http(s) stream.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class RadioPlayerInterface(Protocol):
|
|
153
|
+
"""What the radio playback (:class:`~tinyjukebox.stm.playradio.PlayRadio`) needs of a radio player."""
|
|
154
|
+
|
|
155
|
+
def start(self) -> None: ...
|
|
156
|
+
|
|
157
|
+
def stop(self) -> None: ...
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def state(self) -> RadioState: ...
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def error(self) -> str | None: ...
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Pipeline(Protocol):
|
|
167
|
+
def wait(self) -> tuple[bool, str]: ...
|
|
168
|
+
|
|
169
|
+
def stop(self) -> None: ...
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
PipelineFactory = Callable[[str, Callable[[], None]], Pipeline]
|
|
173
|
+
"""(source, heard) -> pipeline that calls ``heard()`` when its sound is heard"""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class RadioPlayer:
|
|
177
|
+
"""Plays the stream at ``url`` in a thread of its own until :meth:`stop` is called.
|
|
178
|
+
|
|
179
|
+
If the stream cannot be loaded, fails or ends, the player connects again after :data:`RETRY_DELAYS`. ``error``
|
|
180
|
+
is the reason of the last failure (``None`` while the stream plays). ``on_play`` is called whenever ffmpeg has been
|
|
181
|
+
started, ``on_state`` with every new state: ``PLAYING`` only once the sound is heard - aplay plays when its buffer
|
|
182
|
+
is full.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
logger: logging.Logger,
|
|
188
|
+
url: str,
|
|
189
|
+
on_state: StateHook | None = None,
|
|
190
|
+
null_output: bool = False,
|
|
191
|
+
on_play: Callable[[], None] | None = None,
|
|
192
|
+
buffer_time: float | None = None,
|
|
193
|
+
retry_delays: Sequence[float] = RETRY_DELAYS,
|
|
194
|
+
resolve: Callable[[str], str] = resolve_stream_url,
|
|
195
|
+
pipeline_factory: PipelineFactory | None = None,
|
|
196
|
+
output: simpleplayer.AudioOutput | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""``output``: where to play, looked up for every connection (default: the speaker - ``null_output`` and
|
|
199
|
+
``buffer_time``, aplay's buffer in ms, apply only then)."""
|
|
200
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
201
|
+
self.url = url
|
|
202
|
+
self._on_state = on_state
|
|
203
|
+
self._on_play = on_play
|
|
204
|
+
self._retry_delays = tuple(retry_delays) or RETRY_DELAYS
|
|
205
|
+
self._resolve = resolve
|
|
206
|
+
audio_output = output or simpleplayer.AudioOutput(null_output, buffer_time)
|
|
207
|
+
self._pipeline_factory: PipelineFactory = pipeline_factory or (
|
|
208
|
+
lambda source, heard: simpleplayer.AudioPipeline(
|
|
209
|
+
source, output=audio_output.current()[0], stream=True, on_output=self._first_output,
|
|
210
|
+
on_underrun=self._underrun, on_heard=heard, heard_after=self._buffer,
|
|
211
|
+
)
|
|
212
|
+
) # fmt: skip
|
|
213
|
+
buffer_ms = output.buffer_time if output is not None else buffer_time
|
|
214
|
+
self._buffer = buffer_ms / 1000 if buffer_ms else DEFAULT_BUFFER # seconds
|
|
215
|
+
self._began = time.monotonic()
|
|
216
|
+
self._connection: _Connection | None = None
|
|
217
|
+
self._lock = threading.Lock()
|
|
218
|
+
self._stopped = threading.Event()
|
|
219
|
+
self._pipeline: Pipeline | None = None
|
|
220
|
+
self._thread: threading.Thread | None = None
|
|
221
|
+
self._attempts = 0
|
|
222
|
+
self._state = RadioState.STOPPED
|
|
223
|
+
self._error: str | None = None
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def state(self) -> RadioState:
|
|
227
|
+
return self._state
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def error(self) -> str | None:
|
|
231
|
+
return self._error
|
|
232
|
+
|
|
233
|
+
def start(self) -> None:
|
|
234
|
+
"""Start playing - returns immediately (connecting happens in the player thread)."""
|
|
235
|
+
with self._lock:
|
|
236
|
+
if self._thread is not None:
|
|
237
|
+
return
|
|
238
|
+
self._stopped.clear()
|
|
239
|
+
self._began = time.monotonic()
|
|
240
|
+
self._thread = threading.Thread(target=self._run, name="RadioPlayer", daemon=True)
|
|
241
|
+
self._thread.start()
|
|
242
|
+
|
|
243
|
+
def stop(self, timeout: float = 3.0) -> None:
|
|
244
|
+
"""Stop the playback. Waits up to ``timeout`` seconds for the player thread - a thread that still loads a
|
|
245
|
+
playlist ends by itself without starting the stream."""
|
|
246
|
+
with self._lock:
|
|
247
|
+
self._stopped.set()
|
|
248
|
+
pipeline, self._pipeline = self._pipeline, None
|
|
249
|
+
thread, self._thread = self._thread, None
|
|
250
|
+
self._state = RadioState.STOPPED
|
|
251
|
+
if pipeline is not None:
|
|
252
|
+
pipeline.stop()
|
|
253
|
+
if thread is not None and thread is not threading.current_thread():
|
|
254
|
+
thread.join(timeout)
|
|
255
|
+
self._logger.info("stopped")
|
|
256
|
+
|
|
257
|
+
def _set_state(self, state: RadioState, error: str | None = None) -> bool:
|
|
258
|
+
"""Set the state unless the player has been stopped (False then). Called with ``_lock`` held."""
|
|
259
|
+
if self._stopped.is_set():
|
|
260
|
+
return False
|
|
261
|
+
self._state, self._error = state, error
|
|
262
|
+
if self._on_state is not None:
|
|
263
|
+
self._on_state(state)
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
def _run(self) -> None:
|
|
267
|
+
attempt = 0
|
|
268
|
+
while not self._stopped.is_set():
|
|
269
|
+
with self._lock:
|
|
270
|
+
if not self._set_state(RadioState.CONNECTING, self._error):
|
|
271
|
+
break
|
|
272
|
+
result = self._play_once()
|
|
273
|
+
if result is None: # stopped
|
|
274
|
+
break
|
|
275
|
+
error, stable = result
|
|
276
|
+
if stable: # the stream works in general - start over with the shortest delay
|
|
277
|
+
attempt = 0
|
|
278
|
+
delay = self._retry_delays[min(attempt, len(self._retry_delays) - 1)]
|
|
279
|
+
attempt += 1
|
|
280
|
+
self._logger.warning(
|
|
281
|
+
"stream '%s' failed: %s - connecting again in %.0f s", self.url, error, delay,
|
|
282
|
+
extra=self._ended(error, delay),
|
|
283
|
+
) # fmt: skip
|
|
284
|
+
with self._lock:
|
|
285
|
+
if not self._set_state(RadioState.RETRYING, error):
|
|
286
|
+
break
|
|
287
|
+
self._stopped.wait(delay)
|
|
288
|
+
|
|
289
|
+
# --- what the start of a connection took (docs/developer/library.md, section 4.22) -------------------------------
|
|
290
|
+
|
|
291
|
+
def _first_output(self) -> None:
|
|
292
|
+
connection = self._connection
|
|
293
|
+
if connection is None:
|
|
294
|
+
return
|
|
295
|
+
connection.first_output()
|
|
296
|
+
self._logger.info("first audio from the stream after %s ms", connection.startup(self._buffer)["first_audio_ms"])
|
|
297
|
+
|
|
298
|
+
def _heard(self, connection: _Connection) -> None:
|
|
299
|
+
"""aplay's buffer is full and it plays: now the stream is heard."""
|
|
300
|
+
connection.heard()
|
|
301
|
+
with self._lock:
|
|
302
|
+
if self._connection is not connection or self._pipeline is None:
|
|
303
|
+
return
|
|
304
|
+
self._set_state(RadioState.PLAYING)
|
|
305
|
+
self._logger.info(
|
|
306
|
+
"the stream is heard %s ms after the start of the connection",
|
|
307
|
+
connection.startup(self._buffer)["audible_ms"],
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def _underrun(self, ms: float) -> None:
|
|
311
|
+
if self._connection is not None:
|
|
312
|
+
self._connection.underrun(ms)
|
|
313
|
+
|
|
314
|
+
def _report_startup(self, connection: _Connection) -> None:
|
|
315
|
+
"""Once per connection: when its first seconds are over, or when it ends before."""
|
|
316
|
+
with connection.lock:
|
|
317
|
+
if connection.reported:
|
|
318
|
+
return
|
|
319
|
+
connection.reported = True
|
|
320
|
+
measured = connection.startup(self._buffer)
|
|
321
|
+
self._logger.info(
|
|
322
|
+
"start of the stream: playlist %s ms, ffmpeg and aplay %s ms, first audio %s ms, heard %s ms "
|
|
323
|
+
"(buffer %s ms); %d breaks of the sound (%d ms) in the first %.0f s",
|
|
324
|
+
measured["resolve_ms"], measured["pipeline_ms"], measured["first_audio_ms"], measured["audible_ms"],
|
|
325
|
+
measured["buffer_ms"], measured["underruns"], measured["underrun_ms"], STARTUP_WINDOW,
|
|
326
|
+
extra=diagnostic("radio.startup", text=f"start of a stream of {self._host()}", host=self._host(),
|
|
327
|
+
**measured),
|
|
328
|
+
) # fmt: skip
|
|
329
|
+
|
|
330
|
+
def _ended(self, error: str, delay: float) -> dict[str, Any]:
|
|
331
|
+
"""The structured part of the warning about a connection that ended."""
|
|
332
|
+
connection = self._connection
|
|
333
|
+
params: dict[str, Any] = {"reason": _reason(error), "delay_s": delay, "host": self._host()}
|
|
334
|
+
if connection is not None:
|
|
335
|
+
self._report_startup(connection)
|
|
336
|
+
with connection.lock:
|
|
337
|
+
params["attempt"] = connection.attempt
|
|
338
|
+
params["played_s"] = (
|
|
339
|
+
None if connection.started is None else round(time.monotonic() - connection.started)
|
|
340
|
+
)
|
|
341
|
+
params["underruns"] = len(connection.underruns)
|
|
342
|
+
text = f"a stream of {self._host()} ended ({params['reason']}): connecting again in {delay:.0f} s"
|
|
343
|
+
return diagnostic("radio.retry", text=text, **params)
|
|
344
|
+
|
|
345
|
+
def _host(self) -> str:
|
|
346
|
+
"""The station by its host - never the whole address, which may carry a token."""
|
|
347
|
+
return urllib.parse.urlsplit(self.url).hostname or "?"
|
|
348
|
+
|
|
349
|
+
def _play_once(self) -> tuple[str, bool] | None:
|
|
350
|
+
"""Load and play the stream until it ends. Returns (error, played for at least :data:`STABLE_PLAYBACK`
|
|
351
|
+
seconds) or ``None`` if the player has been stopped."""
|
|
352
|
+
connection = self._connection = _Connection(self._attempts, self._began)
|
|
353
|
+
self._attempts += 1
|
|
354
|
+
try:
|
|
355
|
+
source = self._resolve(self.url)
|
|
356
|
+
except (OSError, ValueError) as e:
|
|
357
|
+
connection.resolved = time.monotonic()
|
|
358
|
+
return f"cannot load playlist: {e}", False
|
|
359
|
+
connection.resolved = time.monotonic()
|
|
360
|
+
if source != self.url:
|
|
361
|
+
connection.kind = "playlist"
|
|
362
|
+
self._logger.info("playlist '%s' -> stream '%s'", self.url, source)
|
|
363
|
+
elif urllib.parse.urlsplit(self.url).path.lower().endswith((".m3u8", *PLAYLIST_EXTENSIONS)):
|
|
364
|
+
connection.kind = "hls"
|
|
365
|
+
with self._lock:
|
|
366
|
+
if self._stopped.is_set():
|
|
367
|
+
return None
|
|
368
|
+
try:
|
|
369
|
+
pipeline = self._pipeline_factory(source, functools.partial(self._heard, connection))
|
|
370
|
+
except OSError as e:
|
|
371
|
+
return f"cannot start ffmpeg/aplay: {e}", False
|
|
372
|
+
self._pipeline = pipeline # PLAYING once its sound is heard
|
|
373
|
+
connection.started = started = time.monotonic()
|
|
374
|
+
self._logger.info("started ffmpeg and aplay for stream '%s'", source)
|
|
375
|
+
if self._on_play is not None:
|
|
376
|
+
self._on_play()
|
|
377
|
+
report = threading.Timer(STARTUP_WINDOW, self._report_startup, (connection,))
|
|
378
|
+
report.name, report.daemon = "RadioPlayer.startup", True
|
|
379
|
+
report.start()
|
|
380
|
+
try:
|
|
381
|
+
success, errors = pipeline.wait()
|
|
382
|
+
finally:
|
|
383
|
+
report.cancel()
|
|
384
|
+
self._report_startup(connection) # a connection that ends within its first seconds - also by a stop
|
|
385
|
+
with self._lock:
|
|
386
|
+
if self._stopped.is_set() or self._pipeline is not pipeline:
|
|
387
|
+
return None
|
|
388
|
+
self._pipeline = None
|
|
389
|
+
return errors or (
|
|
390
|
+
"stream ended" if success else "playback failed"
|
|
391
|
+
), time.monotonic() - started >= STABLE_PLAYBACK
|