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