git-cli-yt 1.1.9 → 2.0.0
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.
- package/PRIVACY_POLICY.md +10 -0
- package/README.md +49 -1
- package/app.py +1322 -0
- package/audio/__init__.py +0 -0
- package/audio/audio_player.py +566 -0
- package/bin/cli.js +105 -67
- package/main.py +8 -682
- package/mix/__init__.py +0 -0
- package/mix/mix_service.py +227 -0
- package/package.json +32 -29
- package/requirements.txt +10 -8
- package/utils/__init__.py +0 -0
- package/utils/helpers.py +37 -0
- package/youtube/__init__.py +0 -0
- package/youtube/auth.py +196 -0
- package/youtube/liked.py +121 -0
- package/youtube/playlist.py +139 -0
- package/youtube/search.py +362 -0
|
File without changes
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
import av
|
|
7
|
+
import numpy as np
|
|
8
|
+
import sounddevice as sd
|
|
9
|
+
import yt_dlp
|
|
10
|
+
|
|
11
|
+
Track = dict[str, Any]
|
|
12
|
+
Info = dict[str, Any]
|
|
13
|
+
State = dict[str, Any]
|
|
14
|
+
StateCallback = Callable[[State], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AudioPlayer:
|
|
18
|
+
"""Controla reprodução, fila e controles de áudio."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
# Estado
|
|
22
|
+
self._play_queue: list[Track] = []
|
|
23
|
+
self._current_index: Optional[int] = None
|
|
24
|
+
self._current_track: Optional[Track] = None
|
|
25
|
+
self.is_playing = False
|
|
26
|
+
self.is_paused = False
|
|
27
|
+
self.volume = 0.8
|
|
28
|
+
|
|
29
|
+
# Controle de execução
|
|
30
|
+
self._stop_event = threading.Event()
|
|
31
|
+
self._pause_event = threading.Event()
|
|
32
|
+
self._pause_event.set()
|
|
33
|
+
self._stream_thread: Optional[threading.Thread] = None
|
|
34
|
+
self._stream_generation = 0
|
|
35
|
+
self._navigation_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
# Callbacks para UI
|
|
38
|
+
self.on_state_change: Optional[StateCallback] = None
|
|
39
|
+
self.on_progress: Optional[StateCallback] = None
|
|
40
|
+
self.on_finished: Optional[Callable[[], None]] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------ #
|
|
44
|
+
# Fila
|
|
45
|
+
# ------------------------------------------------------------------ #
|
|
46
|
+
|
|
47
|
+
def set_queue(self, tracks: list[Track]) -> None:
|
|
48
|
+
"""Define a fila de reprodução."""
|
|
49
|
+
self._play_queue = list(tracks)
|
|
50
|
+
self._current_index = None
|
|
51
|
+
self._current_track = None
|
|
52
|
+
|
|
53
|
+
def add_to_queue(self, tracks: list[Track]) -> None:
|
|
54
|
+
"""Adiciona músicas ao final da fila."""
|
|
55
|
+
self._play_queue.extend(tracks)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def queue(self) -> list[Track]:
|
|
59
|
+
return list(self._play_queue)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def current_track(self) -> Optional[Track]:
|
|
63
|
+
return self._current_track
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def current_index(self) -> Optional[int]:
|
|
67
|
+
return self._current_index
|
|
68
|
+
|
|
69
|
+
def queue_length(self) -> int:
|
|
70
|
+
return len(self._play_queue)
|
|
71
|
+
|
|
72
|
+
def _find_index(self, track: Track) -> Optional[int]:
|
|
73
|
+
"""Encontra a faixa pelo identificador mais confiável."""
|
|
74
|
+
video_id = self._extract_video_id(track)
|
|
75
|
+
if video_id:
|
|
76
|
+
for index, queued in enumerate(self._play_queue):
|
|
77
|
+
if self._extract_video_id(queued) == video_id:
|
|
78
|
+
return index
|
|
79
|
+
|
|
80
|
+
url = str(track.get("url", "") or "")
|
|
81
|
+
if url:
|
|
82
|
+
for index, queued in enumerate(self._play_queue):
|
|
83
|
+
if str(queued.get("url", "") or "") == url:
|
|
84
|
+
return index
|
|
85
|
+
|
|
86
|
+
title = str(track.get("title", "") or "").strip().casefold()
|
|
87
|
+
if title:
|
|
88
|
+
for index, queued in enumerate(self._play_queue):
|
|
89
|
+
queued_title = str(queued.get("title", "") or "").strip().casefold()
|
|
90
|
+
if queued_title == title:
|
|
91
|
+
return index
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def _extract_video_id(self, track: Track) -> Optional[str]:
|
|
95
|
+
"""Extrai video ID de um track para matching rápido."""
|
|
96
|
+
if not track:
|
|
97
|
+
return None
|
|
98
|
+
url_str = str(track.get("url", "") or "")
|
|
99
|
+
match = re.search(
|
|
100
|
+
r"youtu\.be/([a-zA-Z0-9_-]+)|watch\?v=([a-zA-Z0-9_-]+)",
|
|
101
|
+
url_str,
|
|
102
|
+
)
|
|
103
|
+
if match:
|
|
104
|
+
return match.group(1) or match.group(2)
|
|
105
|
+
return str(track.get("id") or track.get("_video_id") or "") or None
|
|
106
|
+
# ------------------------------------------------------------------ #
|
|
107
|
+
# Controles de Reprodução
|
|
108
|
+
# ------------------------------------------------------------------ #
|
|
109
|
+
|
|
110
|
+
def play(self, track: Optional[Track] = None) -> None:
|
|
111
|
+
"""Inicia reprodução de uma música (ou retoma pausada)."""
|
|
112
|
+
if track is not None:
|
|
113
|
+
self._stop_current_stream(wait=True)
|
|
114
|
+
idx = self._find_index(track)
|
|
115
|
+
if idx is None:
|
|
116
|
+
# Música não está na fila: insere no topo
|
|
117
|
+
self._play_queue.insert(0, track)
|
|
118
|
+
idx = 0
|
|
119
|
+
self._current_index = idx
|
|
120
|
+
self._current_track = self._play_queue[idx]
|
|
121
|
+
self._start_stream()
|
|
122
|
+
else:
|
|
123
|
+
if self._current_track and not self.is_playing:
|
|
124
|
+
if self.is_paused:
|
|
125
|
+
self._pause_event.set()
|
|
126
|
+
self.is_playing = True
|
|
127
|
+
self.is_paused = False
|
|
128
|
+
self._notify_state()
|
|
129
|
+
else:
|
|
130
|
+
self._stop_event.clear()
|
|
131
|
+
self._start_stream()
|
|
132
|
+
|
|
133
|
+
def play_external(self, track: Track) -> None:
|
|
134
|
+
"""Reproduz uma faixa sem inseri-la no índice da fila."""
|
|
135
|
+
if not track:
|
|
136
|
+
return
|
|
137
|
+
self._stop_current_stream(wait=True)
|
|
138
|
+
self._current_index = None
|
|
139
|
+
self._current_track = track
|
|
140
|
+
self._start_stream()
|
|
141
|
+
|
|
142
|
+
def pause(self) -> None:
|
|
143
|
+
"""Pausa a reprodução."""
|
|
144
|
+
if self.is_playing and not self.is_paused:
|
|
145
|
+
self._pause_event.clear()
|
|
146
|
+
self.is_paused = True
|
|
147
|
+
self.is_playing = False
|
|
148
|
+
self._notify_state()
|
|
149
|
+
|
|
150
|
+
def toggle_pause(self) -> None:
|
|
151
|
+
if self.is_playing and not self.is_paused:
|
|
152
|
+
self.pause()
|
|
153
|
+
elif self.is_paused:
|
|
154
|
+
self.play()
|
|
155
|
+
|
|
156
|
+
def stop(self) -> None:
|
|
157
|
+
"""Para a reprodução completamente."""
|
|
158
|
+
self._stop_event.set()
|
|
159
|
+
self._pause_event.set()
|
|
160
|
+
self._stop_current_stream(wait=True)
|
|
161
|
+
self.is_playing = False
|
|
162
|
+
self.is_paused = False
|
|
163
|
+
self._current_track = None
|
|
164
|
+
self._current_index = None
|
|
165
|
+
self._notify_state()
|
|
166
|
+
|
|
167
|
+
def play_next(self) -> bool:
|
|
168
|
+
"""Avança manualmente exatamente uma faixa."""
|
|
169
|
+
with self._navigation_lock:
|
|
170
|
+
if not self._play_queue:
|
|
171
|
+
return False
|
|
172
|
+
next_idx = (
|
|
173
|
+
0 if self._current_index is None else self._current_index + 1
|
|
174
|
+
)
|
|
175
|
+
if next_idx >= len(self._play_queue):
|
|
176
|
+
return False
|
|
177
|
+
target = self._play_queue[next_idx]
|
|
178
|
+
self._current_index = next_idx
|
|
179
|
+
self._current_track = target
|
|
180
|
+
self.play(target)
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
def play_prev(self) -> bool:
|
|
184
|
+
"""Volta manualmente exatamente uma faixa."""
|
|
185
|
+
with self._navigation_lock:
|
|
186
|
+
if not self._play_queue or self._current_index is None:
|
|
187
|
+
return False
|
|
188
|
+
previous_idx = self._current_index - 1
|
|
189
|
+
if previous_idx < 0:
|
|
190
|
+
return False
|
|
191
|
+
target = self._play_queue[previous_idx]
|
|
192
|
+
self._current_index = previous_idx
|
|
193
|
+
self._current_track = target
|
|
194
|
+
self.play(target)
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def seek(self, delta_seconds: float) -> None:
|
|
198
|
+
"""Busca relativa (+ ou - segundos)."""
|
|
199
|
+
if self._current_track:
|
|
200
|
+
current = self.current_position_seconds
|
|
201
|
+
target = max(0.0, current + delta_seconds)
|
|
202
|
+
self.seek_to(target)
|
|
203
|
+
|
|
204
|
+
def seek_to(self, seconds: float) -> None:
|
|
205
|
+
"""Busca para posição absoluta."""
|
|
206
|
+
if not self._current_track:
|
|
207
|
+
return
|
|
208
|
+
self._stop_event.set()
|
|
209
|
+
self._pause_event.set()
|
|
210
|
+
self._stop_current_stream(wait=True)
|
|
211
|
+
self._current_track["_seek"] = seconds
|
|
212
|
+
self._stop_event.clear()
|
|
213
|
+
self._start_stream()
|
|
214
|
+
|
|
215
|
+
def set_volume(self, level: float) -> None:
|
|
216
|
+
self.volume = max(0.0, min(1.0, level))
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------ #
|
|
219
|
+
# Estado e Notificações
|
|
220
|
+
# ------------------------------------------------------------------ #
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def current_position_seconds(self) -> float:
|
|
224
|
+
if self._current_track:
|
|
225
|
+
return self._current_track.get("_position", 0.0)
|
|
226
|
+
return 0.0
|
|
227
|
+
|
|
228
|
+
@current_position_seconds.setter
|
|
229
|
+
def current_position_seconds(self, value: float) -> None:
|
|
230
|
+
if self._current_track:
|
|
231
|
+
self._current_track["_position"] = value
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def duration_seconds(self) -> float:
|
|
235
|
+
if self._current_track:
|
|
236
|
+
return float(self._current_track.get("duration", 0.0) or 0.0)
|
|
237
|
+
return 0.0
|
|
238
|
+
|
|
239
|
+
def _notify_state(self) -> None:
|
|
240
|
+
if self.on_state_change:
|
|
241
|
+
try:
|
|
242
|
+
self.on_state_change(
|
|
243
|
+
{
|
|
244
|
+
"track": self._current_track,
|
|
245
|
+
"playing": self.is_playing,
|
|
246
|
+
"paused": self.is_paused,
|
|
247
|
+
"volume": self.volume,
|
|
248
|
+
"position": self.current_position_seconds,
|
|
249
|
+
"duration": self.duration_seconds,
|
|
250
|
+
"queue_length": len(self._play_queue),
|
|
251
|
+
"current_index": self._current_index,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def _notify_progress(self) -> None:
|
|
258
|
+
if self.on_progress:
|
|
259
|
+
try:
|
|
260
|
+
self.on_progress(
|
|
261
|
+
{
|
|
262
|
+
"position": self.current_position_seconds,
|
|
263
|
+
"duration": self.duration_seconds,
|
|
264
|
+
"volume": self.volume,
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------ #
|
|
271
|
+
# Stream worker (yt-dlp → PyAV → sounddevice)
|
|
272
|
+
# ------------------------------------------------------------------ #
|
|
273
|
+
|
|
274
|
+
def _stop_current_stream(self, wait: bool = False) -> None:
|
|
275
|
+
"""Invalida a geração atual e encerra a thread de stream."""
|
|
276
|
+
self._stream_generation += 1
|
|
277
|
+
self._stop_event.set()
|
|
278
|
+
self._pause_event.set()
|
|
279
|
+
if self._stream_thread and self._stream_thread.is_alive():
|
|
280
|
+
if wait and threading.current_thread() != self._stream_thread:
|
|
281
|
+
self._stream_thread.join(timeout=3.0)
|
|
282
|
+
|
|
283
|
+
def _start_stream(self) -> None:
|
|
284
|
+
"""Inicia uma nova geração de reprodução."""
|
|
285
|
+
if self._current_track is None:
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
self._stream_generation += 1
|
|
289
|
+
generation = self._stream_generation
|
|
290
|
+
self._stop_event.clear()
|
|
291
|
+
self._pause_event.set()
|
|
292
|
+
self._stream_thread = threading.Thread(
|
|
293
|
+
target=self._stream_worker,
|
|
294
|
+
args=(self._current_track, generation),
|
|
295
|
+
daemon=True,
|
|
296
|
+
)
|
|
297
|
+
self.is_playing = True
|
|
298
|
+
self.is_paused = False
|
|
299
|
+
self._notify_state()
|
|
300
|
+
self._stream_thread.start()
|
|
301
|
+
|
|
302
|
+
def _fetch_info(self, url: str) -> Optional[Info]:
|
|
303
|
+
"""Extrai metadados de áudio via yt-dlp."""
|
|
304
|
+
try:
|
|
305
|
+
ydl_opts: Any = {
|
|
306
|
+
"format": "bestaudio/best",
|
|
307
|
+
"quiet": True,
|
|
308
|
+
"no_warnings": True,
|
|
309
|
+
"extractor_args": {
|
|
310
|
+
"youtube": {"player_client": ["android", "web"]}
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
314
|
+
extracted = ydl.extract_info(url, download=False)
|
|
315
|
+
if extracted:
|
|
316
|
+
info: Info = dict(extracted)
|
|
317
|
+
return info
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
def _stream_is_current(self, generation: int) -> bool:
|
|
323
|
+
return generation == self._stream_generation and not self._stop_event.is_set()
|
|
324
|
+
|
|
325
|
+
def _stream_worker(self, track: Track, generation: int) -> None:
|
|
326
|
+
"""Thread trabalhadora de uma geração específica de áudio."""
|
|
327
|
+
max_retries = 3
|
|
328
|
+
retry_count = 0
|
|
329
|
+
seek_target = track.pop("_seek", None)
|
|
330
|
+
|
|
331
|
+
pcm_buffer = bytearray()
|
|
332
|
+
pcm_lock = threading.Lock()
|
|
333
|
+
played_samples = 0
|
|
334
|
+
last_position_update = time.time()
|
|
335
|
+
sample_rate_holder = [48000]
|
|
336
|
+
|
|
337
|
+
def audio_callback(outdata, frames, time_info, status_flags):
|
|
338
|
+
nonlocal pcm_buffer, played_samples, last_position_update
|
|
339
|
+
bytes_needed = frames * 2 * 4 # stereo float32
|
|
340
|
+
|
|
341
|
+
# Se estiver pausado, interrompe a emissão de som IMEDIATAMENTE (<20ms)
|
|
342
|
+
# sem descartar o buffer pré-decodificado
|
|
343
|
+
if not self._stream_is_current(generation):
|
|
344
|
+
outdata.fill(0)
|
|
345
|
+
return
|
|
346
|
+
if not self._pause_event.is_set():
|
|
347
|
+
outdata.fill(0)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
with pcm_lock:
|
|
351
|
+
if len(pcm_buffer) >= bytes_needed:
|
|
352
|
+
chunk = pcm_buffer[:bytes_needed]
|
|
353
|
+
pcm_buffer = pcm_buffer[bytes_needed:]
|
|
354
|
+
outdata[:] = np.frombuffer(chunk, dtype=np.float32).reshape(
|
|
355
|
+
frames, 2
|
|
356
|
+
)
|
|
357
|
+
played_samples += frames
|
|
358
|
+
now = time.time()
|
|
359
|
+
if now - last_position_update >= 0.2:
|
|
360
|
+
self.current_position_seconds = (
|
|
361
|
+
played_samples / sample_rate_holder[0]
|
|
362
|
+
)
|
|
363
|
+
last_position_update = now
|
|
364
|
+
if self._stream_is_current(generation):
|
|
365
|
+
self._notify_progress()
|
|
366
|
+
else:
|
|
367
|
+
outdata.fill(0)
|
|
368
|
+
|
|
369
|
+
while retry_count <= max_retries and self._stream_is_current(generation):
|
|
370
|
+
try:
|
|
371
|
+
url = track.get("url")
|
|
372
|
+
if not url:
|
|
373
|
+
raise RuntimeError("Faixa sem URL")
|
|
374
|
+
|
|
375
|
+
info = self._fetch_info(url)
|
|
376
|
+
if not info:
|
|
377
|
+
raise RuntimeError("Falha ao extrair informações do áudio")
|
|
378
|
+
if not self._stream_is_current(generation):
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
direct_url = info.get("url", "")
|
|
382
|
+
track["duration"] = info.get("duration", 0) or 0
|
|
383
|
+
headers = info.get("http_headers", {}) or {}
|
|
384
|
+
self.current_position_seconds = 0.0
|
|
385
|
+
played_samples = 0
|
|
386
|
+
|
|
387
|
+
headers_str = "".join(
|
|
388
|
+
[f"{k}: {v}\r\n" for k, v in headers.items()]
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
container_options = {
|
|
392
|
+
"user_agent": headers.get(
|
|
393
|
+
"User-Agent",
|
|
394
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
|
395
|
+
),
|
|
396
|
+
"headers": headers_str,
|
|
397
|
+
"reconnect": "1",
|
|
398
|
+
"reconnect_streamed": "1",
|
|
399
|
+
"reconnect_delay_max": "5",
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
container: Any = av.open(direct_url, options=container_options)
|
|
403
|
+
audio_stream: Any = next(
|
|
404
|
+
(s for s in container.streams if s.type == "audio"), None
|
|
405
|
+
)
|
|
406
|
+
if audio_stream is None:
|
|
407
|
+
container.close()
|
|
408
|
+
raise RuntimeError("Nenhuma faixa de áudio encontrada")
|
|
409
|
+
|
|
410
|
+
sample_rate = audio_stream.codec_context.sample_rate or 48000
|
|
411
|
+
sample_rate_holder[0] = sample_rate
|
|
412
|
+
time_base = (
|
|
413
|
+
float(audio_stream.time_base)
|
|
414
|
+
if audio_stream.time_base is not None
|
|
415
|
+
else 1.0 / sample_rate
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
resampler: Any = av.AudioResampler(
|
|
419
|
+
format="fltp", layout="stereo", rate=sample_rate
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
with sd.OutputStream(
|
|
423
|
+
samplerate=sample_rate,
|
|
424
|
+
channels=2,
|
|
425
|
+
dtype="float32",
|
|
426
|
+
callback=audio_callback,
|
|
427
|
+
blocksize=1024,
|
|
428
|
+
):
|
|
429
|
+
if not self._pause_event.is_set():
|
|
430
|
+
self.is_playing = False
|
|
431
|
+
self.is_paused = True
|
|
432
|
+
else:
|
|
433
|
+
self.is_playing = True
|
|
434
|
+
self.is_paused = False
|
|
435
|
+
self._notify_state()
|
|
436
|
+
|
|
437
|
+
if seek_target is not None:
|
|
438
|
+
target_pts = int(seek_target / time_base)
|
|
439
|
+
container.seek(target_pts, stream=audio_stream)
|
|
440
|
+
played_samples = int(seek_target * sample_rate)
|
|
441
|
+
with pcm_lock:
|
|
442
|
+
pcm_buffer.clear()
|
|
443
|
+
self.current_position_seconds = seek_target
|
|
444
|
+
seek_target = None
|
|
445
|
+
|
|
446
|
+
last_ui_update = 0.0
|
|
447
|
+
|
|
448
|
+
for frame in container.decode(audio_stream):
|
|
449
|
+
if not self._stream_is_current(generation):
|
|
450
|
+
break
|
|
451
|
+
|
|
452
|
+
max_buffer_bytes = sample_rate * 2 * 4 * 3
|
|
453
|
+
while (
|
|
454
|
+
len(pcm_buffer) > max_buffer_bytes
|
|
455
|
+
and self._stream_is_current(generation)
|
|
456
|
+
):
|
|
457
|
+
time.sleep(0.05)
|
|
458
|
+
|
|
459
|
+
self._pause_event.wait()
|
|
460
|
+
|
|
461
|
+
resampled = resampler.resample(frame)
|
|
462
|
+
if not resampled:
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
for r_frame in resampled:
|
|
466
|
+
if not self._stream_is_current(generation):
|
|
467
|
+
break
|
|
468
|
+
arr = r_frame.to_ndarray()
|
|
469
|
+
if arr.ndim == 1:
|
|
470
|
+
arr = np.vstack((arr, arr))
|
|
471
|
+
arr = arr * self.volume
|
|
472
|
+
audio_data = np.ascontiguousarray(
|
|
473
|
+
arr.T, dtype=np.float32
|
|
474
|
+
)
|
|
475
|
+
with pcm_lock:
|
|
476
|
+
pcm_buffer.extend(audio_data.tobytes())
|
|
477
|
+
|
|
478
|
+
now = time.time()
|
|
479
|
+
if now - last_ui_update >= 0.2:
|
|
480
|
+
self.current_position_seconds = (
|
|
481
|
+
played_samples / sample_rate
|
|
482
|
+
)
|
|
483
|
+
if self._stream_is_current(generation):
|
|
484
|
+
self._notify_progress()
|
|
485
|
+
last_ui_update = now
|
|
486
|
+
|
|
487
|
+
while len(pcm_buffer) > 0 and self._stream_is_current(generation):
|
|
488
|
+
self.current_position_seconds = (
|
|
489
|
+
played_samples / sample_rate
|
|
490
|
+
)
|
|
491
|
+
if time.time() - last_ui_update >= 0.2:
|
|
492
|
+
self._notify_progress()
|
|
493
|
+
time.sleep(0.1)
|
|
494
|
+
|
|
495
|
+
container.close()
|
|
496
|
+
|
|
497
|
+
if not self._stream_is_current(generation):
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
# Música finalizou normalmente
|
|
501
|
+
self.is_playing = False
|
|
502
|
+
self._notify_state()
|
|
503
|
+
self._play_next(track, generation)
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
except Exception:
|
|
507
|
+
if not self._stream_is_current(generation):
|
|
508
|
+
return
|
|
509
|
+
retry_count += 1
|
|
510
|
+
if retry_count <= max_retries:
|
|
511
|
+
time.sleep(1)
|
|
512
|
+
else:
|
|
513
|
+
self.is_playing = False
|
|
514
|
+
self._stop_event.set()
|
|
515
|
+
self._notify_state()
|
|
516
|
+
self._play_next(track, generation)
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
if self._stream_is_current(generation):
|
|
520
|
+
self.is_playing = False
|
|
521
|
+
self._notify_state()
|
|
522
|
+
self._play_next(track, generation)
|
|
523
|
+
|
|
524
|
+
def _same_track(self, left: Optional[Track], right: Optional[Track]) -> bool:
|
|
525
|
+
if left is right:
|
|
526
|
+
return True
|
|
527
|
+
if not left or not right:
|
|
528
|
+
return False
|
|
529
|
+
left_id = self._extract_video_id(left)
|
|
530
|
+
right_id = self._extract_video_id(right)
|
|
531
|
+
return bool(left_id and left_id == right_id)
|
|
532
|
+
|
|
533
|
+
def _play_next(
|
|
534
|
+
self,
|
|
535
|
+
finished_track: Optional[Track] = None,
|
|
536
|
+
generation: Optional[int] = None,
|
|
537
|
+
) -> None:
|
|
538
|
+
"""Avança apenas quando a thread que terminou ainda é a atual."""
|
|
539
|
+
if generation is not None and generation != self._stream_generation:
|
|
540
|
+
return
|
|
541
|
+
if finished_track is not None and not self._same_track(
|
|
542
|
+
finished_track, self._current_track
|
|
543
|
+
):
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
with self._navigation_lock:
|
|
547
|
+
if generation is not None and generation != self._stream_generation:
|
|
548
|
+
return
|
|
549
|
+
if finished_track is not None and not self._same_track(
|
|
550
|
+
finished_track, self._current_track
|
|
551
|
+
):
|
|
552
|
+
return
|
|
553
|
+
if self.on_finished:
|
|
554
|
+
try:
|
|
555
|
+
self.on_finished()
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
if not self._play_queue:
|
|
559
|
+
return
|
|
560
|
+
next_idx = 0 if self._current_index is None else self._current_index + 1
|
|
561
|
+
if next_idx >= len(self._play_queue):
|
|
562
|
+
return
|
|
563
|
+
target = self._play_queue[next_idx]
|
|
564
|
+
self._current_index = next_idx
|
|
565
|
+
self._current_track = target
|
|
566
|
+
self.play(target)
|