git-cli-yt 1.1.9 → 1.2.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.
Files changed (3) hide show
  1. package/bin/cli.js +1 -1
  2. package/main.py +527 -144
  3. package/package.json +1 -1
package/bin/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  const { execSync, spawn } = require("child_process");
4
4
  const path = require("path");
package/main.py CHANGED
@@ -1,21 +1,21 @@
1
1
  import json
2
2
  import os
3
+ import queue
3
4
  import socket
4
5
  import sys
5
6
  import threading
6
7
  import time
7
8
  from pathlib import Path
8
9
 
10
+ import av
9
11
  from google.auth.transport.requests import Request
12
+
10
13
  from google.oauth2.credentials import Credentials
11
14
  from google_auth_oauthlib.flow import InstalledAppFlow
12
15
  from googleapiclient.discovery import build
13
16
  from googleapiclient.errors import HttpError
14
-
15
- import av
16
17
  import numpy as np
17
18
  import sounddevice as sd
18
- import yt_dlp
19
19
  from textual.app import App, ComposeResult
20
20
  from textual.containers import Horizontal, Vertical
21
21
  from textual.widgets import (
@@ -29,10 +29,12 @@ from textual.widgets import (
29
29
  ProgressBar,
30
30
  Select,
31
31
  )
32
+ import yt_dlp
32
33
 
33
- # Tenta carregar variáveis do .env se existir, sem obrigar o usuário a ter um
34
+ # Tenta carregar variáveis do .env se existir
34
35
  try:
35
36
  from dotenv import load_dotenv
37
+
36
38
  load_dotenv(Path.cwd() / ".env")
37
39
  load_dotenv(Path(__file__).resolve().parent / ".env")
38
40
  except ImportError:
@@ -40,7 +42,7 @@ except ImportError:
40
42
 
41
43
  socket.setdefaulttimeout(15)
42
44
 
43
- # Diretório para salvar o token do usuário na pasta de configurações do sistema
45
+ # Diretório para salvar o token do usuário
44
46
  CONFIG_DIR = Path.home() / ".config" / "meu-player-tui"
45
47
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
46
48
  TOKEN_FILE = CONFIG_DIR / "token.json"
@@ -48,17 +50,14 @@ TOKEN_FILE = CONFIG_DIR / "token.json"
48
50
  SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
49
51
  MUSIC_CATEGORY_ID = "10"
50
52
 
51
- # Credenciais padrão da aplicação Desktop para o fluxo do usuário
52
53
  DEFAULT_CLIENT_ID = os.environ.get(
53
54
  "GOOGLE_CLIENT_ID",
54
- "50790974670-qshvlqkejhu0ksj76v0t0lpcq3krm593.apps.googleusercontent.com" # Substitua pelas suas credenciais reais do GCP (Desktop App)
55
+ "50790974670-qshvlqkejhu0ksj76v0t0lpcq3krm593.apps.googleusercontent.com",
55
56
  )
56
57
  DEFAULT_CLIENT_SECRET = os.environ.get(
57
- "GOOGLE_CLIENT_SECRET",
58
- "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" # Substitua pelas suas credenciais reais do GCP (Desktop App)
58
+ "GOOGLE_CLIENT_SECRET", "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"
59
59
  )
60
60
 
61
-
62
61
  def get_client_config():
63
62
  """Retorna a configuração OAuth 2.0 padrão para aplicação desktop."""
64
63
  return {
@@ -73,12 +72,14 @@ def get_client_config():
73
72
 
74
73
 
75
74
  def get_youtube_service(interactive=False):
76
- """Obtém a conexão com a API do YouTube para ver as curtidas do usuário."""
75
+ """Obtém a conexão com a API do YouTube."""
77
76
  creds = None
78
77
 
79
78
  if TOKEN_FILE.exists():
80
79
  try:
81
- creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)
80
+ creds = Credentials.from_authorized_user_file(
81
+ str(TOKEN_FILE), SCOPES
82
+ )
82
83
  except Exception:
83
84
  creds = None
84
85
 
@@ -115,79 +116,193 @@ def get_youtube_service(interactive=False):
115
116
 
116
117
  class MusicPlayerApp(App):
117
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
+
118
129
  Screen {
119
130
  layout: vertical;
131
+ background: $youtube-bg;
132
+ color: $youtube-text;
133
+ }
134
+
135
+ Header {
136
+ height: 3;
120
137
  }
121
138
 
122
139
  #main {
123
140
  height: 1fr;
141
+ width: 100%;
142
+ layout: horizontal;
124
143
  }
125
144
 
126
145
  #sidebar {
127
- width: 44;
146
+ width: 28;
128
147
  height: 100%;
129
- background: $panel;
130
- border-right: heavy $accent;
131
- padding: 1;
148
+ background: $youtube-panel-dark;
149
+ border-right: solid $youtube-border;
150
+ padding: 1 1;
132
151
  }
133
152
 
134
- #sidebar Input {
153
+ #sidebar-title {
154
+ height: 3;
155
+ content-align: center middle;
156
+ text-style: bold;
157
+ color: $youtube-red;
135
158
  margin-bottom: 1;
136
159
  }
137
160
 
138
- #auth-buttons {
139
- height: 3;
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;
140
200
  margin-bottom: 1;
141
201
  }
142
202
 
143
- #auth-buttons Button {
144
- margin-right: 1;
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;
145
222
  }
146
223
 
147
224
  #results_list {
225
+ width: 100%;
148
226
  height: 1fr;
149
- border: solid $accent;
227
+ background: $youtube-bg;
228
+ border: solid $youtube-border;
229
+ margin-top: 1;
150
230
  }
151
231
 
152
- #btn_load_more {
232
+ #liked_results_list {
153
233
  width: 100%;
234
+ height: 1fr;
235
+ background: $youtube-bg;
236
+ border: solid $youtube-border;
154
237
  margin-top: 1;
155
238
  }
156
239
 
157
- #content {
158
- width: 1fr;
159
- height: 100%;
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;
160
261
  padding: 2;
161
- align: center middle;
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;
162
275
  }
163
276
 
164
277
  #now-playing {
278
+ width: 100%;
279
+ height: 2;
165
280
  text-align: center;
166
281
  text-style: bold;
167
- margin-bottom: 1;
168
282
  }
169
283
 
170
284
  #visualizer {
285
+ width: 100%;
286
+ height: 1;
171
287
  text-align: center;
172
- color: $success;
173
- height: 2;
174
- margin-bottom: 1;
288
+ color: $youtube-red;
175
289
  }
176
290
 
177
291
  #progress-container {
178
- height: 3;
292
+ width: 100%;
293
+ height: 2;
179
294
  align: center middle;
180
- margin-bottom: 1;
181
295
  }
182
296
 
183
297
  #progress-container ProgressBar {
184
298
  width: 1fr;
299
+ margin: 0 1;
185
300
  }
186
301
 
187
302
  #volume-container {
303
+ width: 100%;
188
304
  height: 3;
189
305
  align: center middle;
190
- margin-bottom: 1;
191
306
  }
192
307
 
193
308
  #volume-container Button {
@@ -195,6 +310,7 @@ class MusicPlayerApp(App):
195
310
  }
196
311
 
197
312
  #controls {
313
+ width: 100%;
198
314
  height: 3;
199
315
  align: center middle;
200
316
  }
@@ -204,8 +320,44 @@ class MusicPlayerApp(App):
204
320
  }
205
321
 
206
322
  #status {
207
- margin-top: 1;
208
- color: $text-muted;
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;
209
361
  }
210
362
  """
211
363
 
@@ -239,66 +391,190 @@ class MusicPlayerApp(App):
239
391
  self.duration_seconds = 0
240
392
  self.current_position_seconds = 0
241
393
  self.seek_target_seconds = None
394
+ self.current_page = "home"
242
395
 
243
396
  def compose(self) -> ComposeResult:
244
397
  yield Header()
245
398
 
246
399
  with Horizontal(id="main"):
247
400
  with Vertical(id="sidebar"):
248
- yield Label("[bold]Buscar ou Conta[/bold]")
249
- yield Input(placeholder="Nome ou artista...", id="search_input")
250
-
251
- with Horizontal(id="auth-buttons"):
252
- yield Button("🔑 Login", id="btn_login", variant="primary")
253
- yield Button("👍 Curtidas", id="btn_liked", variant="default")
254
-
255
- yield Label("[bold]Filtro de Conteúdo:[/bold]")
256
- yield Select(
257
- [("🎵 Apenas Músicas", "music"), ("🎬 Todos os Vídeos", "all")],
258
- value="music",
259
- id="filter_select",
260
- allow_blank=False,
261
- )
262
-
263
- yield ListView(id="results_list")
264
- yield Button("➕ Carregar Mais Músicas", id="btn_load_more", variant="default")
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")
265
408
 
266
409
  with Vertical(id="content"):
267
- yield Label("Nenhuma música selecionada", id="now-playing")
268
-
269
- yield Label("░░░░░░░░░░░░░░░░░░░░", id="visualizer")
270
-
271
- # Linha do Tempo e Progresso
272
- with Horizontal(id="progress-container"):
273
- yield Label("00:00 ", id="time_current")
274
- yield ProgressBar(id="song_progress", total=100, show_percentage=False)
275
- yield Label(" 00:00", id="time_total")
276
-
277
- # Controle de Volume
278
- with Horizontal(id="volume-container"):
279
- yield Button("🔉 -", id="btn_vol_down")
280
- yield Label(" Volume: 80% ", id="vol_label")
281
- yield Button("🔊 +", id="btn_vol_up")
282
-
283
- with Horizontal(id="controls"):
284
- yield Button("⏪ -10s", id="btn_rewind")
285
- yield Button("▶ / ⏸ (Espaço)", id="btn_toggle", variant="primary")
286
- yield Button("⏩ +10s", id="btn_forward")
287
- yield Button("⏹ Parar", id="btn_stop", variant="error")
288
-
289
- yield Label("Status: Pronto.", id="status")
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")
290
469
 
291
470
  yield Footer()
292
471
 
293
472
  def on_mount(self) -> None:
473
+ self._show_page("home")
474
+
294
475
  def auto_auth():
295
476
  self.youtube_api = get_youtube_service(interactive=False)
296
477
  status = self.query_one("#status", Label)
297
478
  if self.youtube_api:
298
- self.call_from_thread(status.update, "[green]Sessão de usuário carregada com sucesso![/green]")
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)
299
486
 
300
487
  threading.Thread(target=auto_auth, daemon=True).start()
301
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
+
302
578
  # --- FILTRAGEM E ATUALIZAÇÃO ---
303
579
 
304
580
  def on_select_changed(self, event: Select.Changed) -> None:
@@ -315,7 +591,8 @@ class MusicPlayerApp(App):
315
591
  else:
316
592
  self.filtered_results = list(self.raw_results)
317
593
 
318
- list_view = self.query_one("#results_list", ListView)
594
+ list_id = "#liked_results_list" if self.current_page == "liked" else "#results_list"
595
+ list_view = self.query_one(list_id, ListView)
319
596
  list_view.clear()
320
597
 
321
598
  for item in self.filtered_results:
@@ -323,7 +600,9 @@ class MusicPlayerApp(App):
323
600
  list_view.append(ListItem(Label(f"{tag} {item['title']}")))
324
601
 
325
602
  status = self.query_one("#status", Label)
326
- status.update(f"[green]Exibindo {len(self.filtered_results)} item(ns).[/green]")
603
+ status.update(
604
+ f"[green]Exibindo {len(self.filtered_results)} item(ns).[/green]"
605
+ )
327
606
 
328
607
  # --- AUTENTICAÇÃO E CURTIDAS COM PAGINAÇÃO ---
329
608
 
@@ -333,9 +612,14 @@ class MusicPlayerApp(App):
333
612
 
334
613
  try:
335
614
  self.youtube_api = get_youtube_service(interactive=True)
336
- self.call_from_thread(status.update, "[green]Login realizado com sucesso![/green]")
615
+ self.call_from_thread(
616
+ status.update, "[green]Login realizado com sucesso![/green]"
617
+ )
618
+ self.call_from_thread(self._update_login_page)
337
619
  except Exception as e:
338
- self.call_from_thread(status.update, f"[red]Erro na autenticação: {e}[/red]")
620
+ self.call_from_thread(
621
+ status.update, f"[red]Erro na autenticação: {e}[/red]"
622
+ )
339
623
 
340
624
  def _fetch_liked_videos(self, load_more=False) -> None:
341
625
  status = self.query_one("#status", Label)
@@ -364,9 +648,10 @@ class MusicPlayerApp(App):
364
648
  {
365
649
  "title": item["snippet"]["title"],
366
650
  "url": f"https://www.youtube.com/watch?v={item['id']}",
367
- "is_music": item["snippet"].get("categoryId") == MUSIC_CATEGORY_ID,
651
+ "is_music": True,
368
652
  }
369
653
  for item in items
654
+ if item.get("snippet", {}).get("categoryId") == MUSIC_CATEGORY_ID
370
655
  ]
371
656
 
372
657
  if load_more:
@@ -380,7 +665,9 @@ class MusicPlayerApp(App):
380
665
 
381
666
  except Exception as e:
382
667
  self.is_loading_more = False
383
- self.call_from_thread(status.update, f"[red]Erro ao carregar curtidas: {e}[/red]")
668
+ self.call_from_thread(
669
+ status.update, f"[red]Erro ao carregar curtidas: {e}[/red]"
670
+ )
384
671
 
385
672
  if not self.is_loading_more:
386
673
  self.is_loading_more = True
@@ -405,28 +692,31 @@ class MusicPlayerApp(App):
405
692
  ).start()
406
693
 
407
694
  def _search_youtube(self, query: str, load_more=False) -> None:
408
- offset = len(self.raw_results) + 1 if load_more else 1
695
+ count = 15
696
+ search_query = f"ytsearch{count}:{query}"
409
697
 
410
698
  ydl_opts = {
411
- "format": "bestaudio/best/best",
412
699
  "quiet": True,
413
- "playliststart": offset,
414
- "playlistend": offset + 15,
415
- "default_search": f"ytsearch{offset + 15}",
416
- "noplaylist": True,
700
+ "extract_flat": True,
701
+ "no_warnings": True,
702
+ "nocheckcertificate": True,
703
+ "extractor_args": {
704
+ "youtube": {"player_client": ["mweb", "web", "android"]}
705
+ },
417
706
  }
418
707
 
419
708
  try:
420
709
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
421
- info = ydl.extract_info(query, download=False)
422
- entries = info.get("entries", [])
710
+ info = ydl.extract_info(search_query, download=False)
711
+ entries = info.get("entries", []) if info else []
423
712
 
424
713
  new_entries = [
425
714
  {
426
- "title": entry.get("title"),
427
- "url": entry.get("webpage_url") or entry.get("url"),
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')}",
428
718
  "duration": entry.get("duration", 0),
429
- "is_music": entry.get("categories") and "Music" in entry.get("categories") or True,
719
+ "is_music": True,
430
720
  }
431
721
  for entry in entries
432
722
  if entry
@@ -456,7 +746,9 @@ class MusicPlayerApp(App):
456
746
  elif self.last_source_type == "search" and self.last_query:
457
747
  self.is_loading_more = True
458
748
  threading.Thread(
459
- target=self._search_youtube, args=(self.last_query, True), daemon=True
749
+ target=self._search_youtube,
750
+ args=(self.last_query, True),
751
+ daemon=True,
460
752
  ).start()
461
753
 
462
754
  # --- REPRODUÇÃO E ÁUDIO ---
@@ -476,6 +768,10 @@ class MusicPlayerApp(App):
476
768
 
477
769
  self._stop_audio()
478
770
 
771
+ self.current_position_seconds = 0
772
+ self.duration_seconds = 0
773
+ self.seek_target_seconds = None
774
+
479
775
  now_playing.update(f"[bold cyan]Tocando:[/bold cyan] {track['title']}")
480
776
  status.update("[yellow]Obtendo áudio...[/yellow]")
481
777
 
@@ -486,81 +782,150 @@ class MusicPlayerApp(App):
486
782
  self.stop_event.clear()
487
783
  self.pause_event.set()
488
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
+
489
805
  while retry_count <= max_retries and not self.stop_event.is_set():
490
806
  try:
491
807
  ydl_opts = {
492
- "format": "bestaudio/best",
808
+ "format": "bestaudio/best/b",
493
809
  "quiet": True,
810
+ "no_warnings": True,
494
811
  "nocheckcertificate": True,
812
+ "extractor_args": {
813
+ "youtube": {"player_client": ["mweb", "web", "android"]}
814
+ },
495
815
  }
496
816
 
497
817
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
498
818
  info = ydl.extract_info(track["url"], download=False)
499
819
  direct_url = info["url"]
500
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()])
501
824
 
502
825
  container_options = {
503
- "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
826
+ "user_agent": headers.get(
827
+ "User-Agent",
828
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
829
+ ),
830
+ "headers": headers_str,
504
831
  "reconnect": "1",
505
832
  "reconnect_streamed": "1",
506
833
  "reconnect_delay_max": "5",
507
834
  }
508
835
 
509
836
  container = av.open(direct_url, options=container_options)
510
- audio_stream = next(s for s in container.streams if s.type == "audio")
511
- sample_rate = audio_stream.codec_context.sample_rate or 48000
512
- time_base = float(audio_stream.time_base)
513
837
 
514
- if self.current_position_seconds > 0:
515
- target_pts = int(self.current_position_seconds / time_base)
516
- container.seek(target_pts, stream=audio_stream)
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.")
517
844
 
518
- resampler = av.AudioResampler(format="fltp", layout="stereo", rate=sample_rate)
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
+ )
519
853
 
520
854
  with sd.OutputStream(
521
- samplerate=sample_rate, channels=2, dtype="float32"
522
- ) as output_stream:
855
+ samplerate=sample_rate,
856
+ channels=2,
857
+ dtype="float32",
858
+ callback=audio_callback,
859
+ blocksize=1024,
860
+ ):
523
861
  self.is_playing = True
524
- self.call_from_thread(status.update, "[green]Reproduzindo ♪[/green]")
862
+ self.call_from_thread(
863
+ status.update, "[green]Reproduzindo ♪[/green]"
864
+ )
525
865
 
526
- for packet in container.demux(audio_stream):
866
+ last_ui_update = 0
867
+
868
+ for frame in container.decode(audio_stream):
527
869
  if self.stop_event.is_set():
528
870
  break
529
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
+
530
877
  if self.seek_target_seconds is not None:
531
- target_pts = int(self.seek_target_seconds / time_base)
878
+ target_pts = int(
879
+ self.seek_target_seconds / time_base
880
+ )
532
881
  container.seek(target_pts, stream=audio_stream)
533
- self.current_position_seconds = self.seek_target_seconds
882
+ played_samples = int(self.seek_target_seconds * sample_rate)
883
+ with pcm_lock:
884
+ pcm_buffer.clear()
534
885
  self.seek_target_seconds = None
535
- continue
536
-
537
- for frame in packet.decode():
538
- if self.stop_event.is_set():
539
- break
540
886
 
541
- self.pause_event.wait()
887
+ resampler = av.AudioResampler(
888
+ format="fltp",
889
+ layout="stereo",
890
+ rate=sample_rate,
891
+ )
892
+ continue
542
893
 
543
- if frame.pts is not None:
544
- self.current_position_seconds = frame.pts * time_base
894
+ self.pause_event.wait()
545
895
 
546
- resampled_frames = resampler.resample(frame)
547
- if not resampled_frames:
548
- continue
896
+ resampled_frames = resampler.resample(frame)
897
+ if not resampled_frames:
898
+ continue
549
899
 
550
- for r_frame in resampled_frames:
551
- audio_array = r_frame.to_ndarray()
900
+ for r_frame in resampled_frames:
901
+ if self.stop_event.is_set():
902
+ break
552
903
 
553
- if audio_array.ndim == 1:
554
- audio_array = np.vstack((audio_array, audio_array))
904
+ audio_array = r_frame.to_ndarray()
905
+ if audio_array.ndim == 1:
906
+ audio_array = np.vstack((audio_array, audio_array))
555
907
 
556
- audio_array = audio_array * self.volume
557
- audio_data = np.ascontiguousarray(audio_array.T, dtype=np.float32)
908
+ audio_array = audio_array * self.volume
909
+ audio_data = np.ascontiguousarray(audio_array.T, dtype=np.float32)
558
910
 
559
- output_stream.write(audio_data)
911
+ with pcm_lock:
912
+ pcm_buffer.extend(audio_data.tobytes())
560
913
 
561
- rms = np.sqrt(np.mean(audio_data ** 2))
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
562
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)
563
927
 
928
+ container.close()
564
929
  break
565
930
 
566
931
  except (av.FFmpegError, OSError, Exception) as e:
@@ -571,21 +936,27 @@ class MusicPlayerApp(App):
571
936
  if retry_count <= max_retries:
572
937
  self.call_from_thread(
573
938
  status.update,
574
- f"[yellow]Conexão perdida. Reconectando ({retry_count}/{max_retries})...[/yellow]",
939
+ f"[yellow]Reconectando ({retry_count}/{max_retries})...[/yellow]",
575
940
  )
576
941
  time.sleep(1)
577
942
  else:
578
943
  self.is_playing = False
579
- self.call_from_thread(status.update, f"[red]Erro ao tocar: {e}[/red]")
944
+ self.call_from_thread(
945
+ status.update, f"[red]Erro ao tocar: {e}[/red]"
946
+ )
580
947
 
581
948
  self.is_playing = False
582
949
  self.call_from_thread(self._reset_playback_ui)
583
950
 
584
- self.stream_thread = threading.Thread(target=stream_worker, daemon=True)
951
+ self.stream_thread = threading.Thread(
952
+ target=stream_worker, daemon=True
953
+ )
585
954
  self.stream_thread.start()
586
955
 
587
956
  def _update_playback_ui(self, rms_volume: float) -> None:
588
- curr_str = time.strftime("%M:%S", time.gmtime(self.current_position_seconds))
957
+ curr_str = time.strftime(
958
+ "%M:%S", time.gmtime(self.current_position_seconds)
959
+ )
589
960
  tot_str = time.strftime("%M:%S", time.gmtime(self.duration_seconds))
590
961
 
591
962
  self.query_one("#time_current", Label).update(f"{curr_str} ")
@@ -593,7 +964,10 @@ class MusicPlayerApp(App):
593
964
 
594
965
  progress_bar = self.query_one("#song_progress", ProgressBar)
595
966
  if self.duration_seconds > 0:
596
- progress_bar.progress = (self.current_position_seconds / self.duration_seconds) * 100
967
+ progress_bar.progress = min(
968
+ 100.0,
969
+ (self.current_position_seconds / self.duration_seconds) * 100,
970
+ )
597
971
 
598
972
  bars = [" ", " ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
599
973
  level = min(int(rms_volume * 35), len(bars) - 1)
@@ -611,10 +985,14 @@ class MusicPlayerApp(App):
611
985
  # --- CONTROLES E ATALHOS ---
612
986
 
613
987
  def _stop_audio(self) -> None:
988
+ """Interrompe e encerra síncronamente qualquer thread de reprodução em execução."""
614
989
  self.stop_event.set()
615
990
  self.pause_event.set()
991
+
616
992
  if self.stream_thread and self.stream_thread.is_alive():
617
- self.stream_thread.join(timeout=1.0)
993
+ if threading.current_thread() != self.stream_thread:
994
+ self.stream_thread.join(timeout=2.0)
995
+
618
996
  self.is_playing = False
619
997
 
620
998
  def action_toggle_play(self) -> None:
@@ -639,13 +1017,24 @@ class MusicPlayerApp(App):
639
1017
 
640
1018
  def on_button_pressed(self, event: Button.Pressed) -> None:
641
1019
  b_id = event.button.id
642
- if b_id == "btn_toggle":
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":
643
1030
  self.toggle_audio()
644
1031
  elif b_id == "btn_stop":
645
1032
  self._stop_audio()
646
1033
  self._reset_playback_ui()
647
1034
  self.query_one("#status", Label).update("Reprodução parada.")
648
- self.query_one("#now-playing", Label).update("Nenhuma música selecionada")
1035
+ self.query_one("#now-playing", Label).update(
1036
+ "Nenhuma música selecionada"
1037
+ )
649
1038
  elif b_id == "btn_vol_up":
650
1039
  self._adjust_volume(0.1)
651
1040
  elif b_id == "btn_vol_down":
@@ -654,13 +1043,7 @@ class MusicPlayerApp(App):
654
1043
  self._seek(-10)
655
1044
  elif b_id == "btn_forward":
656
1045
  self._seek(10)
657
- elif b_id == "btn_login":
658
- threading.Thread(target=self._authenticate_youtube, daemon=True).start()
659
- elif b_id == "btn_liked":
660
- self.last_query = ""
661
- self.next_page_token = None
662
- self._fetch_liked_videos()
663
- elif b_id == "btn_load_more":
1046
+ elif b_id in {"btn_load_more", "btn_liked_load_more"}:
664
1047
  self._load_more_content()
665
1048
 
666
1049
  def toggle_audio(self) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-cli-yt",
3
- "version": "1.1.9",
3
+ "version": "1.2.0",
4
4
  "description": "YouTube TUI Player executável via NPX",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {