git-cli-yt 1.2.0 → 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/main.py CHANGED
@@ -1,1065 +1,8 @@
1
- import json
2
- import os
3
- import queue
4
- import socket
5
- import sys
6
- import threading
7
- import time
8
- from pathlib import Path
9
-
10
- import av
11
- from google.auth.transport.requests import Request
12
-
13
- from google.oauth2.credentials import Credentials
14
- from google_auth_oauthlib.flow import InstalledAppFlow
15
- from googleapiclient.discovery import build
16
- from googleapiclient.errors import HttpError
17
- import numpy as np
18
- import sounddevice as sd
19
- from textual.app import App, ComposeResult
20
- from textual.containers import Horizontal, Vertical
21
- from textual.widgets import (
22
- Button,
23
- Footer,
24
- Header,
25
- Input,
26
- Label,
27
- ListItem,
28
- ListView,
29
- ProgressBar,
30
- Select,
31
- )
32
- import yt_dlp
33
-
34
- # Tenta carregar variáveis do .env se existir
35
- try:
36
- from dotenv import load_dotenv
37
-
38
- load_dotenv(Path.cwd() / ".env")
39
- load_dotenv(Path(__file__).resolve().parent / ".env")
40
- except ImportError:
41
- pass
42
-
43
- socket.setdefaulttimeout(15)
44
-
45
- # Diretório para salvar o token do usuário
46
- CONFIG_DIR = Path.home() / ".config" / "meu-player-tui"
47
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
48
- TOKEN_FILE = CONFIG_DIR / "token.json"
49
-
50
- SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
51
- MUSIC_CATEGORY_ID = "10"
52
-
53
- DEFAULT_CLIENT_ID = os.environ.get(
54
- "GOOGLE_CLIENT_ID",
55
- "50790974670-qshvlqkejhu0ksj76v0t0lpcq3krm593.apps.googleusercontent.com",
56
- )
57
- DEFAULT_CLIENT_SECRET = os.environ.get(
58
- "GOOGLE_CLIENT_SECRET", "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"
59
- )
60
-
61
- def get_client_config():
62
- """Retorna a configuração OAuth 2.0 padrão para aplicação desktop."""
63
- return {
64
- "installed": {
65
- "client_id": DEFAULT_CLIENT_ID,
66
- "client_secret": DEFAULT_CLIENT_SECRET,
67
- "auth_uri": "https://accounts.google.com/o/oauth2/auth",
68
- "token_uri": "https://oauth2.googleapis.com/token",
69
- "redirect_uris": ["http://localhost:8080/"],
70
- }
71
- }
72
-
73
-
74
- def get_youtube_service(interactive=False):
75
- """Obtém a conexão com a API do YouTube."""
76
- creds = None
77
-
78
- if TOKEN_FILE.exists():
79
- try:
80
- creds = Credentials.from_authorized_user_file(
81
- str(TOKEN_FILE), SCOPES
82
- )
83
- except Exception:
84
- creds = None
85
-
86
- if creds and creds.expired and creds.refresh_token:
87
- try:
88
- creds.refresh(Request())
89
- with open(TOKEN_FILE, "w", encoding="utf-8") as token:
90
- token.write(creds.to_json())
91
- except Exception:
92
- creds = None
93
-
94
- if not creds or not creds.valid:
95
- if not interactive:
96
- return None
97
-
98
- client_config = get_client_config()
99
- flow = InstalledAppFlow.from_client_config(
100
- client_config,
101
- SCOPES,
102
- redirect_uri="http://localhost:8080/",
103
- )
104
-
105
- creds = flow.run_local_server(
106
- port=8080,
107
- prompt="consent",
108
- access_type="offline",
109
- )
110
-
111
- with open(TOKEN_FILE, "w", encoding="utf-8") as token:
112
- token.write(creds.to_json())
113
-
114
- return build("youtube", "v3", credentials=creds)
115
-
116
-
117
- class MusicPlayerApp(App):
118
- CSS = """
119
- $youtube-bg: #0f0f0f;
120
- $youtube-panel: #212121;
121
- $youtube-panel-dark: #181818;
122
- $youtube-hover: #3f3f3f;
123
- $youtube-border: #303030;
124
- $youtube-text: #f1f1f1;
125
- $youtube-muted: #aaaaaa;
126
- $youtube-red: #ff0000;
127
- $youtube-red-dark: #cc0000;
128
-
129
- Screen {
130
- layout: vertical;
131
- background: $youtube-bg;
132
- color: $youtube-text;
133
- }
134
-
135
- Header {
136
- height: 3;
137
- }
138
-
139
- #main {
140
- height: 1fr;
141
- width: 100%;
142
- layout: horizontal;
143
- }
144
-
145
- #sidebar {
146
- width: 28;
147
- height: 100%;
148
- background: $youtube-panel-dark;
149
- border-right: solid $youtube-border;
150
- padding: 1 1;
151
- }
152
-
153
- #sidebar-title {
154
- height: 3;
155
- content-align: center middle;
156
- text-style: bold;
157
- color: $youtube-red;
158
- margin-bottom: 1;
159
- }
160
-
161
- .nav-button {
162
- width: 100%;
163
- margin-bottom: 1;
164
- background: $youtube-panel-dark;
165
- color: $youtube-text;
166
- border: none;
167
- }
168
-
169
- .nav-button:hover {
170
- background: $youtube-hover;
171
- }
172
-
173
- .nav-button.-primary {
174
- background: $youtube-red;
175
- color: #ffffff;
176
- }
177
-
178
- .nav-button.-primary:hover {
179
- background: $youtube-red-dark;
180
- }
181
-
182
- #nav-spacer {
183
- height: 1fr;
184
- }
185
-
186
- #content {
187
- width: 1fr;
188
- height: 100%;
189
- padding: 2 3;
190
- }
191
-
192
- .page {
193
- width: 100%;
194
- height: 100%;
195
- }
196
-
197
- .page-title {
198
- text-style: bold;
199
- color: $youtube-text;
200
- margin-bottom: 1;
201
- }
202
-
203
- #search_input {
204
- width: 100%;
205
- margin-bottom: 1;
206
- background: $youtube-panel-dark;
207
- color: $youtube-text;
208
- border: solid $youtube-border;
209
- }
210
-
211
- #search_input:focus {
212
- border: solid $youtube-text;
213
- }
214
-
215
- #search-toolbar {
216
- height: auto;
217
- margin-bottom: 1;
218
- }
219
-
220
- #filter_select {
221
- width: 32;
222
- }
223
-
224
- #results_list {
225
- width: 100%;
226
- height: 1fr;
227
- background: $youtube-bg;
228
- border: solid $youtube-border;
229
- margin-top: 1;
230
- }
231
-
232
- #liked_results_list {
233
- width: 100%;
234
- height: 1fr;
235
- background: $youtube-bg;
236
- border: solid $youtube-border;
237
- margin-top: 1;
238
- }
239
-
240
- #btn_load_more, #btn_liked_load_more {
241
- width: 100%;
242
- margin-top: 1;
243
- background: $youtube-panel;
244
- color: $youtube-text;
245
- border: solid $youtube-border;
246
- }
247
-
248
- #btn_load_more:hover, #btn_liked_load_more:hover {
249
- background: $youtube-hover;
250
- }
251
-
252
- #liked_hint, #login_hint, #settings_hint {
253
- color: $youtube-muted;
254
- }
255
-
256
- #login-panel {
257
- width: 100%;
258
- height: auto;
259
- border: round $youtube-border;
260
- background: $youtube-panel-dark;
261
- padding: 2;
262
- }
263
-
264
- #login-user {
265
- margin: 1 0;
266
- text-style: bold;
267
- }
268
-
269
- #player {
270
- height: 11;
271
- width: 100%;
272
- background: $youtube-panel;
273
- border-top: solid $youtube-border;
274
- padding: 1 2;
275
- }
276
-
277
- #now-playing {
278
- width: 100%;
279
- height: 2;
280
- text-align: center;
281
- text-style: bold;
282
- }
283
-
284
- #visualizer {
285
- width: 100%;
286
- height: 1;
287
- text-align: center;
288
- color: $youtube-red;
289
- }
290
-
291
- #progress-container {
292
- width: 100%;
293
- height: 2;
294
- align: center middle;
295
- }
296
-
297
- #progress-container ProgressBar {
298
- width: 1fr;
299
- margin: 0 1;
300
- }
301
-
302
- #volume-container {
303
- width: 100%;
304
- height: 3;
305
- align: center middle;
306
- }
307
-
308
- #volume-container Button {
309
- margin: 0 1;
310
- }
311
-
312
- #controls {
313
- width: 100%;
314
- height: 3;
315
- align: center middle;
316
- }
317
-
318
- #controls Button {
319
- margin: 0 1;
320
- }
321
-
322
- #status {
323
- width: 100%;
324
- height: 1;
325
- text-align: center;
326
- color: $youtube-muted;
327
- }
328
-
329
- .danger-nav {
330
- color: $youtube-red;
331
- }
332
-
333
- #controls Button {
334
- background: $youtube-panel-dark;
335
- color: $youtube-text;
336
- border: solid $youtube-border;
337
- }
338
-
339
- #controls Button:hover {
340
- background: $youtube-hover;
341
- }
342
-
343
- #controls #btn_toggle.-primary {
344
- background: $youtube-red;
345
- color: #ffffff;
346
- border: none;
347
- }
348
-
349
- #controls #btn_toggle.-primary:hover {
350
- background: $youtube-red-dark;
351
- }
352
-
353
- #controls #btn_stop.-error {
354
- background: $youtube-red;
355
- color: #ffffff;
356
- border: none;
357
- }
358
-
359
- #controls #btn_stop.-error:hover {
360
- background: $youtube-red-dark;
361
- }
362
- """
363
-
364
- BINDINGS = [
365
- ("q", "quit", "Sair"),
366
- ("space", "toggle_play", "Play/Pause"),
367
- ("up", "volume_up", "Aumentar Vol"),
368
- ("down", "volume_down", "Diminuir Vol"),
369
- ]
370
-
371
- def __init__(self):
372
- super().__init__()
373
- self.stream_thread = None
374
- self.stop_event = threading.Event()
375
- self.pause_event = threading.Event()
376
- self.pause_event.set()
377
-
378
- self.is_playing = False
379
- self.raw_results = []
380
- self.filtered_results = []
381
- self.youtube_api = None
382
-
383
- # Paginação e Busca
384
- self.next_page_token = None
385
- self.last_query = ""
386
- self.last_source_type = None # 'search' ou 'liked'
387
- self.is_loading_more = False
388
-
389
- # Áudio
390
- self.volume = 0.8
391
- self.duration_seconds = 0
392
- self.current_position_seconds = 0
393
- self.seek_target_seconds = None
394
- self.current_page = "home"
395
-
396
- def compose(self) -> ComposeResult:
397
- yield Header()
398
-
399
- with Horizontal(id="main"):
400
- with Vertical(id="sidebar"):
401
- yield Label("🎵 MEU PLAYER", id="sidebar-title")
402
- yield Button("🏠 Início", id="btn_home", classes="nav-button", variant="primary")
403
- yield Button("❤️ Curtidas", id="btn_liked", classes="nav-button")
404
- yield Button("👤 Login", id="btn_login", classes="nav-button")
405
- yield Button("⚙️ Configurações", id="btn_settings", classes="nav-button")
406
- yield Label("", id="nav-spacer")
407
- yield Button("🚪 Sair", id="btn_exit", classes="nav-button danger-nav")
408
-
409
- with Vertical(id="content"):
410
- with Vertical(id="home_page", classes="page"):
411
- yield Label("Início", classes="page-title")
412
- yield Input(
413
- placeholder="Pesquise por música, artista ou álbum...",
414
- id="search_input",
415
- )
416
- with Horizontal(id="search-toolbar"):
417
- yield Select(
418
- [
419
- ("🎵 Apenas Músicas", "music"),
420
- ("🎬 Todos os Vídeos", "all"),
421
- ],
422
- value="music",
423
- id="filter_select",
424
- allow_blank=False,
425
- )
426
- yield ListView(id="results_list")
427
- yield Button("➕ Carregar mais", id="btn_load_more")
428
-
429
- with Vertical(id="liked_page", classes="page"):
430
- yield Label("Curtidas", classes="page-title")
431
- yield Label(
432
- "Suas músicas curtidas no YouTube.",
433
- id="liked_hint",
434
- )
435
- yield ListView(id="liked_results_list")
436
- yield Button("➕ Carregar mais", id="btn_liked_load_more")
437
-
438
- with Vertical(id="login_page", classes="page"):
439
- yield Label("Login", classes="page-title")
440
- with Vertical(id="login-panel"):
441
- yield Label("Verificando sessão...", id="login-user")
442
- yield Label(
443
- "Conecte sua conta do YouTube para acessar suas curtidas.",
444
- id="login_hint",
445
- )
446
- yield Button("🔑 Entrar com Google", id="btn_login_action", variant="primary")
447
- yield Button("🔄 Trocar conta", id="btn_logout_account")
448
-
449
- with Vertical(id="settings_page", classes="page"):
450
- yield Label("Configurações", classes="page-title")
451
- yield Label("🚧 Em construção...", id="settings_hint")
452
-
453
- with Vertical(id="player"):
454
- yield Label("Nenhuma música selecionada", id="now-playing")
455
- yield Label("░░░░░░░░░░░░░░░░░░░░", id="visualizer")
456
- with Horizontal(id="progress-container"):
457
- yield Label("00:00 ", id="time_current")
458
- yield ProgressBar(id="song_progress", total=100, show_percentage=False)
459
- yield Label(" 00:00", id="time_total")
460
- with Horizontal(id="controls"):
461
- yield Button("⏪ -10s", id="btn_rewind")
462
- yield Button("▶ / ⏸", id="btn_toggle", variant="primary")
463
- yield Button("⏩ +10s", id="btn_forward")
464
- yield Button("⏹ Parar", id="btn_stop", variant="error")
465
- yield Button("🔉 -", id="btn_vol_down")
466
- yield Label("Volume: 80%", id="vol_label")
467
- yield Button("🔊 +", id="btn_vol_up")
468
- yield Label("Status: Pronto.", id="status")
469
-
470
- yield Footer()
471
-
472
- def on_mount(self) -> None:
473
- self._show_page("home")
474
-
475
- def auto_auth():
476
- self.youtube_api = get_youtube_service(interactive=False)
477
- status = self.query_one("#status", Label)
478
- if self.youtube_api:
479
- self.call_from_thread(
480
- status.update,
481
- "[green]Sessão de usuário carregada com sucesso![/green]",
482
- )
483
- self.call_from_thread(self._update_login_page)
484
- else:
485
- self.call_from_thread(self._update_login_page)
486
-
487
- threading.Thread(target=auto_auth, daemon=True).start()
488
-
489
- def _show_page(self, page: str) -> None:
490
- pages = {
491
- "home": "#home_page",
492
- "liked": "#liked_page",
493
- "login": "#login_page",
494
- "settings": "#settings_page",
495
- }
496
- for name, selector in pages.items():
497
- self.query_one(selector).display = name == page
498
-
499
- self.current_page = page
500
- nav_buttons = {
501
- "home": "#btn_home",
502
- "liked": "#btn_liked",
503
- "login": "#btn_login",
504
- "settings": "#btn_settings",
505
- }
506
- for name, selector in nav_buttons.items():
507
- button = self.query_one(selector, Button)
508
- button.variant = "primary" if name == page else "default"
509
-
510
- if page == "login":
511
- self._update_login_page()
512
-
513
- def _update_login_page(self) -> None:
514
- user_label = self.query_one("#login-user", Label)
515
- login_button = self.query_one("#btn_login_action", Button)
516
- logout_button = self.query_one("#btn_logout_account", Button)
517
-
518
- if not self.youtube_api:
519
- user_label.update("Nenhuma conta conectada")
520
- login_button.display = True
521
- logout_button.display = False
522
- return
523
-
524
- user_label.update("Conta conectada. Carregando usuário...")
525
- login_button.display = False
526
- logout_button.display = True
527
-
528
- def fetch_user():
529
- try:
530
- response = self.youtube_api.channels().list(
531
- part="snippet",
532
- mine=True,
533
- ).execute()
534
- items = response.get("items", [])
535
- if items:
536
- name = items[0].get("snippet", {}).get("title", "Conta do YouTube")
537
- else:
538
- name = "Conta do YouTube"
539
- self.call_from_thread(
540
- user_label.update,
541
- f"👤 {name}",
542
- )
543
- except Exception:
544
- self.call_from_thread(user_label.update, "👤 Conta do YouTube conectada")
545
-
546
- threading.Thread(target=fetch_user, daemon=True).start()
547
-
548
- def _logout_youtube(self) -> None:
549
- self._stop_audio()
550
- try:
551
- if TOKEN_FILE.exists():
552
- TOKEN_FILE.unlink()
553
- except OSError as e:
554
- self.query_one("#status", Label).update(
555
- f"[red]Erro ao remover sessão: {e}[/red]"
556
- )
557
- return
558
-
559
- self.youtube_api = None
560
- self.query_one("#status", Label).update("Sessão encerrada.")
561
- self._update_login_page()
562
-
563
- def _handle_navigation(self, button_id: str) -> None:
564
- if button_id == "btn_home":
565
- self._show_page("home")
566
- elif button_id == "btn_liked":
567
- self._show_page("liked")
568
- self.last_query = ""
569
- self.next_page_token = None
570
- self._fetch_liked_videos()
571
- elif button_id == "btn_login":
572
- self._show_page("login")
573
- elif button_id == "btn_settings":
574
- self._show_page("settings")
575
- elif button_id == "btn_exit":
576
- self.exit()
577
-
578
- # --- FILTRAGEM E ATUALIZAÇÃO ---
579
-
580
- def on_select_changed(self, event: Select.Changed) -> None:
581
- if event.select.id == "filter_select":
582
- self._apply_filter_and_update_ui()
583
-
584
- def _apply_filter_and_update_ui(self) -> None:
585
- filter_type = self.query_one("#filter_select", Select).value
586
-
587
- if filter_type == "music":
588
- self.filtered_results = [
589
- item for item in self.raw_results if item.get("is_music")
590
- ]
591
- else:
592
- self.filtered_results = list(self.raw_results)
593
-
594
- list_id = "#liked_results_list" if self.current_page == "liked" else "#results_list"
595
- list_view = self.query_one(list_id, ListView)
596
- list_view.clear()
597
-
598
- for item in self.filtered_results:
599
- tag = "🎵" if item.get("is_music") else "🎬"
600
- list_view.append(ListItem(Label(f"{tag} {item['title']}")))
601
-
602
- status = self.query_one("#status", Label)
603
- status.update(
604
- f"[green]Exibindo {len(self.filtered_results)} item(ns).[/green]"
605
- )
606
-
607
- # --- AUTENTICAÇÃO E CURTIDAS COM PAGINAÇÃO ---
608
-
609
- def _authenticate_youtube(self) -> None:
610
- status = self.query_one("#status", Label)
611
- status.update("[yellow]Abra o navegador para autorizar o acesso...[/yellow]")
612
-
613
- try:
614
- self.youtube_api = get_youtube_service(interactive=True)
615
- self.call_from_thread(
616
- status.update, "[green]Login realizado com sucesso![/green]"
617
- )
618
- self.call_from_thread(self._update_login_page)
619
- except Exception as e:
620
- self.call_from_thread(
621
- status.update, f"[red]Erro na autenticação: {e}[/red]"
622
- )
623
-
624
- def _fetch_liked_videos(self, load_more=False) -> None:
625
- status = self.query_one("#status", Label)
626
- status.update("[yellow]Carregando músicas curtidas...[/yellow]")
627
-
628
- def fetch_task():
629
- try:
630
- if not self.youtube_api:
631
- self.youtube_api = get_youtube_service(interactive=True)
632
-
633
- kwargs = {
634
- "part": "snippet",
635
- "myRating": "like",
636
- "maxResults": 20,
637
- }
638
- if load_more and self.next_page_token:
639
- kwargs["pageToken"] = self.next_page_token
640
-
641
- request = self.youtube_api.videos().list(**kwargs)
642
- response = request.execute()
643
-
644
- items = response.get("items", [])
645
- self.next_page_token = response.get("nextPageToken")
646
-
647
- new_items = [
648
- {
649
- "title": item["snippet"]["title"],
650
- "url": f"https://www.youtube.com/watch?v={item['id']}",
651
- "is_music": True,
652
- }
653
- for item in items
654
- if item.get("snippet", {}).get("categoryId") == MUSIC_CATEGORY_ID
655
- ]
656
-
657
- if load_more:
658
- self.raw_results.extend(new_items)
659
- else:
660
- self.raw_results = new_items
661
-
662
- self.last_source_type = "liked"
663
- self.is_loading_more = False
664
- self.call_from_thread(self._apply_filter_and_update_ui)
665
-
666
- except Exception as e:
667
- self.is_loading_more = False
668
- self.call_from_thread(
669
- status.update, f"[red]Erro ao carregar curtidas: {e}[/red]"
670
- )
671
-
672
- if not self.is_loading_more:
673
- self.is_loading_more = True
674
- threading.Thread(target=fetch_task, daemon=True).start()
675
-
676
- # --- BUSCA COM YT-DLP E PAGINAÇÃO ---
677
-
678
- def on_input_submitted(self, event: Input.Submitted) -> None:
679
- query = event.value.strip()
680
- if not query:
681
- return
682
-
683
- self.last_query = query
684
- self.next_page_token = None
685
- self.raw_results = []
686
-
687
- status = self.query_one("#status", Label)
688
- status.update("[yellow]Buscando no YouTube...[/yellow]")
689
-
690
- threading.Thread(
691
- target=self._search_youtube, args=(query, False), daemon=True
692
- ).start()
693
-
694
- def _search_youtube(self, query: str, load_more=False) -> None:
695
- count = 15
696
- search_query = f"ytsearch{count}:{query}"
697
-
698
- ydl_opts = {
699
- "quiet": True,
700
- "extract_flat": True,
701
- "no_warnings": True,
702
- "nocheckcertificate": True,
703
- "extractor_args": {
704
- "youtube": {"player_client": ["mweb", "web", "android"]}
705
- },
706
- }
707
-
708
- try:
709
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
710
- info = ydl.extract_info(search_query, download=False)
711
- entries = info.get("entries", []) if info else []
712
-
713
- new_entries = [
714
- {
715
- "title": entry.get("title", "Sem título"),
716
- "url": entry.get("url")
717
- or f"https://www.youtube.com/watch?v={entry.get('id')}",
718
- "duration": entry.get("duration", 0),
719
- "is_music": True,
720
- }
721
- for entry in entries
722
- if entry
723
- ]
724
-
725
- if load_more:
726
- self.raw_results.extend(new_entries)
727
- else:
728
- self.raw_results = new_entries
729
-
730
- self.last_source_type = "search"
731
- self.is_loading_more = False
732
- self.call_from_thread(self._apply_filter_and_update_ui)
733
- except Exception as e:
734
- self.is_loading_more = False
735
- self.call_from_thread(
736
- self.query_one("#status", Label).update,
737
- f"[red]Erro na busca: {e}[/red]",
738
- )
739
-
740
- def _load_more_content(self) -> None:
741
- if self.is_loading_more:
742
- return
743
-
744
- if self.last_source_type == "liked":
745
- self._fetch_liked_videos(load_more=True)
746
- elif self.last_source_type == "search" and self.last_query:
747
- self.is_loading_more = True
748
- threading.Thread(
749
- target=self._search_youtube,
750
- args=(self.last_query, True),
751
- daemon=True,
752
- ).start()
753
-
754
- # --- REPRODUÇÃO E ÁUDIO ---
755
-
756
- def on_list_view_selected(self, event: ListView.Selected) -> None:
757
- index = event.list_view.index
758
- if index is not None and index < len(self.filtered_results):
759
- selected_track = self.filtered_results[index]
760
- self._play_stream(selected_track)
761
-
762
- if index >= len(self.filtered_results) - 2:
763
- self._load_more_content()
764
-
765
- def _play_stream(self, track: dict) -> None:
766
- now_playing = self.query_one("#now-playing", Label)
767
- status = self.query_one("#status", Label)
768
-
769
- self._stop_audio()
770
-
771
- self.current_position_seconds = 0
772
- self.duration_seconds = 0
773
- self.seek_target_seconds = None
774
-
775
- now_playing.update(f"[bold cyan]Tocando:[/bold cyan] {track['title']}")
776
- status.update("[yellow]Obtendo áudio...[/yellow]")
777
-
778
- def stream_worker():
779
- max_retries = 3
780
- retry_count = 0
781
-
782
- self.stop_event.clear()
783
- self.pause_event.set()
784
-
785
- pcm_buffer = bytearray()
786
- pcm_lock = threading.Lock()
787
- played_samples = 0
788
-
789
- def audio_callback(outdata, frames, time_info, status_flags):
790
- nonlocal pcm_buffer, played_samples
791
- bytes_needed = frames * 2 * 4 # frames * 2 canais * 4 bytes (float32)
792
-
793
- with pcm_lock:
794
- if len(pcm_buffer) >= bytes_needed:
795
- chunk = pcm_buffer[:bytes_needed]
796
- pcm_buffer = pcm_buffer[bytes_needed:]
797
- outdata[:] = np.frombuffer(chunk, dtype=np.float32).reshape(
798
- frames, 2
799
- )
800
- if self.pause_event.is_set():
801
- played_samples += frames
802
- else:
803
- outdata.fill(0)
804
-
805
- while retry_count <= max_retries and not self.stop_event.is_set():
806
- try:
807
- ydl_opts = {
808
- "format": "bestaudio/best/b",
809
- "quiet": True,
810
- "no_warnings": True,
811
- "nocheckcertificate": True,
812
- "extractor_args": {
813
- "youtube": {"player_client": ["mweb", "web", "android"]}
814
- },
815
- }
816
-
817
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
818
- info = ydl.extract_info(track["url"], download=False)
819
- direct_url = info["url"]
820
- self.duration_seconds = info.get("duration", 0)
821
- headers = info.get("http_headers", {})
822
-
823
- headers_str = "".join([f"{k}: {v}\r\n" for k, v in headers.items()])
824
-
825
- container_options = {
826
- "user_agent": headers.get(
827
- "User-Agent",
828
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
829
- ),
830
- "headers": headers_str,
831
- "reconnect": "1",
832
- "reconnect_streamed": "1",
833
- "reconnect_delay_max": "5",
834
- }
835
-
836
- container = av.open(direct_url, options=container_options)
837
-
838
- audio_stream = next(
839
- (s for s in container.streams if s.type == "audio"), None
840
- )
841
-
842
- if audio_stream is None:
843
- raise ValueError("Nenhuma faixa de áudio encontrada no fluxo do vídeo.")
844
-
845
- sample_rate = (
846
- audio_stream.codec_context.sample_rate or 48000
847
- )
848
- time_base = float(audio_stream.time_base)
849
-
850
- resampler = av.AudioResampler(
851
- format="fltp", layout="stereo", rate=sample_rate
852
- )
853
-
854
- with sd.OutputStream(
855
- samplerate=sample_rate,
856
- channels=2,
857
- dtype="float32",
858
- callback=audio_callback,
859
- blocksize=1024,
860
- ):
861
- self.is_playing = True
862
- self.call_from_thread(
863
- status.update, "[green]Reproduzindo ♪[/green]"
864
- )
865
-
866
- last_ui_update = 0
867
-
868
- for frame in container.decode(audio_stream):
869
- if self.stop_event.is_set():
870
- break
871
-
872
- # Controla a velocidade de decodificação para não sobrecarregar o buffer
873
- max_buffer_bytes = sample_rate * 2 * 4 * 3 # ~3 segundos de áudio
874
- while len(pcm_buffer) > max_buffer_bytes and not self.stop_event.is_set():
875
- time.sleep(0.05)
876
-
877
- if self.seek_target_seconds is not None:
878
- target_pts = int(
879
- self.seek_target_seconds / time_base
880
- )
881
- container.seek(target_pts, stream=audio_stream)
882
- played_samples = int(self.seek_target_seconds * sample_rate)
883
- with pcm_lock:
884
- pcm_buffer.clear()
885
- self.seek_target_seconds = None
886
-
887
- resampler = av.AudioResampler(
888
- format="fltp",
889
- layout="stereo",
890
- rate=sample_rate,
891
- )
892
- continue
893
-
894
- self.pause_event.wait()
895
-
896
- resampled_frames = resampler.resample(frame)
897
- if not resampled_frames:
898
- continue
899
-
900
- for r_frame in resampled_frames:
901
- if self.stop_event.is_set():
902
- break
903
-
904
- audio_array = r_frame.to_ndarray()
905
- if audio_array.ndim == 1:
906
- audio_array = np.vstack((audio_array, audio_array))
907
-
908
- audio_array = audio_array * self.volume
909
- audio_data = np.ascontiguousarray(audio_array.T, dtype=np.float32)
910
-
911
- with pcm_lock:
912
- pcm_buffer.extend(audio_data.tobytes())
913
-
914
- # Atualização da interface sincronizada por tempo real de reprodução
915
- current_time = time.time()
916
- if current_time - last_ui_update >= 0.2:
917
- self.current_position_seconds = played_samples / sample_rate
918
- rms = np.sqrt(np.mean(audio_data**2)) if len(audio_data) > 0 else 0
919
- self.call_from_thread(self._update_playback_ui, rms)
920
- last_ui_update = current_time
921
-
922
- # Aguarda consumir os últimos bytes de áudio no buffer antes de finalizar
923
- while len(pcm_buffer) > 0 and not self.stop_event.is_set():
924
- self.current_position_seconds = played_samples / sample_rate
925
- self.call_from_thread(self._update_playback_ui, 0)
926
- time.sleep(0.1)
927
-
928
- container.close()
929
- break
930
-
931
- except (av.FFmpegError, OSError, Exception) as e:
932
- if self.stop_event.is_set():
933
- break
934
-
935
- retry_count += 1
936
- if retry_count <= max_retries:
937
- self.call_from_thread(
938
- status.update,
939
- f"[yellow]Reconectando ({retry_count}/{max_retries})...[/yellow]",
940
- )
941
- time.sleep(1)
942
- else:
943
- self.is_playing = False
944
- self.call_from_thread(
945
- status.update, f"[red]Erro ao tocar: {e}[/red]"
946
- )
947
-
948
- self.is_playing = False
949
- self.call_from_thread(self._reset_playback_ui)
950
-
951
- self.stream_thread = threading.Thread(
952
- target=stream_worker, daemon=True
953
- )
954
- self.stream_thread.start()
955
-
956
- def _update_playback_ui(self, rms_volume: float) -> None:
957
- curr_str = time.strftime(
958
- "%M:%S", time.gmtime(self.current_position_seconds)
959
- )
960
- tot_str = time.strftime("%M:%S", time.gmtime(self.duration_seconds))
961
-
962
- self.query_one("#time_current", Label).update(f"{curr_str} ")
963
- self.query_one("#time_total", Label).update(f" {tot_str}")
964
-
965
- progress_bar = self.query_one("#song_progress", ProgressBar)
966
- if self.duration_seconds > 0:
967
- progress_bar.progress = min(
968
- 100.0,
969
- (self.current_position_seconds / self.duration_seconds) * 100,
970
- )
971
-
972
- bars = [" ", " ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
973
- level = min(int(rms_volume * 35), len(bars) - 1)
974
- char = bars[level]
975
-
976
- meter_str = f"░▒▓█ {char * 12} █▓▒░"
977
- self.query_one("#visualizer", Label).update(meter_str)
978
-
979
- def _reset_playback_ui(self) -> None:
980
- self.query_one("#visualizer", Label).update("░░░░░░░░░░░░░░░░░░░░")
981
- self.query_one("#song_progress", ProgressBar).progress = 0
982
- self.query_one("#time_current", Label).update("00:00 ")
983
- self.query_one("#time_total", Label).update(" 00:00")
984
-
985
- # --- CONTROLES E ATALHOS ---
986
-
987
- def _stop_audio(self) -> None:
988
- """Interrompe e encerra síncronamente qualquer thread de reprodução em execução."""
989
- self.stop_event.set()
990
- self.pause_event.set()
991
-
992
- if self.stream_thread and self.stream_thread.is_alive():
993
- if threading.current_thread() != self.stream_thread:
994
- self.stream_thread.join(timeout=2.0)
995
-
996
- self.is_playing = False
997
-
998
- def action_toggle_play(self) -> None:
999
- self.toggle_audio()
1000
-
1001
- def action_volume_up(self) -> None:
1002
- self._adjust_volume(0.05)
1003
-
1004
- def action_volume_down(self) -> None:
1005
- self._adjust_volume(-0.05)
1006
-
1007
- def _adjust_volume(self, delta: float) -> None:
1008
- self.volume = max(0.0, min(1.0, self.volume + delta))
1009
- vol_pct = int(self.volume * 100)
1010
- self.query_one("#vol_label", Label).update(f" Volume: {vol_pct}% ")
1011
-
1012
- def _seek(self, seconds_delta: float) -> None:
1013
- new_pos = max(0, self.current_position_seconds + seconds_delta)
1014
- if self.duration_seconds > 0:
1015
- new_pos = min(self.duration_seconds - 1, new_pos)
1016
- self.seek_target_seconds = new_pos
1017
-
1018
- def on_button_pressed(self, event: Button.Pressed) -> None:
1019
- b_id = event.button.id
1020
- if b_id in {"btn_home", "btn_liked", "btn_login", "btn_settings", "btn_exit"}:
1021
- self._handle_navigation(b_id)
1022
- elif b_id == "btn_login_action":
1023
- self._show_page("login")
1024
- threading.Thread(
1025
- target=self._authenticate_youtube, daemon=True
1026
- ).start()
1027
- elif b_id == "btn_logout_account":
1028
- self._logout_youtube()
1029
- elif b_id == "btn_toggle":
1030
- self.toggle_audio()
1031
- elif b_id == "btn_stop":
1032
- self._stop_audio()
1033
- self._reset_playback_ui()
1034
- self.query_one("#status", Label).update("Reprodução parada.")
1035
- self.query_one("#now-playing", Label).update(
1036
- "Nenhuma música selecionada"
1037
- )
1038
- elif b_id == "btn_vol_up":
1039
- self._adjust_volume(0.1)
1040
- elif b_id == "btn_vol_down":
1041
- self._adjust_volume(-0.1)
1042
- elif b_id == "btn_rewind":
1043
- self._seek(-10)
1044
- elif b_id == "btn_forward":
1045
- self._seek(10)
1046
- elif b_id in {"btn_load_more", "btn_liked_load_more"}:
1047
- self._load_more_content()
1048
-
1049
- def toggle_audio(self) -> None:
1050
- if not self.stream_thread or not self.stream_thread.is_alive():
1051
- return
1052
-
1053
- if self.is_playing:
1054
- self.pause_event.clear()
1055
- self.is_playing = False
1056
- self.query_one("#status", Label).update("Pausado ⏸")
1057
- else:
1058
- self.pause_event.set()
1059
- self.is_playing = True
1060
- self.query_one("#status", Label).update("Reproduzindo ♪")
1061
-
1062
-
1063
- if __name__ == "__main__":
1064
- app = MusicPlayerApp()
1065
- app.run()
1
+ """Launcher da V2 - abre a interface Textual."""
2
+
3
+ from app import YouTubePlayer
4
+
5
+
6
+ if __name__ == "__main__":
7
+ app = YouTubePlayer()
8
+ app.run()