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