git-cli-yt 1.1.3

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.
@@ -0,0 +1,7 @@
1
+ # Política de Privacidade - CLI YT Music Player
2
+
3
+ O aplicativo CLI YT Music Player utiliza a API do YouTube apenas para leitura de dados necessários para reprodução de áudio local.
4
+
5
+ 1. **Coleta de Dados:** O aplicativo não coleta, armazena ou compartilha dados pessoais em servidores externos.
6
+ 2. **Autenticação:** O token de acesso OAuth é armazenado exclusivamente de forma local e segura no gerenciador de credenciais do sistema do usuário (`keyring`).
7
+ 3. **Uso de Dados:** O acesso à conta do YouTube é estritamente limitado à leitura da lista de vídeos curtidos (`youtube.readonly`) para exibição na interface do usuário.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # CLI-YT
package/bin/cli.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync, spawn } = require("child_process");
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+
8
+ // Carrega o arquivo .env da pasta atual
9
+ require("dotenv").config({ path: path.join(process.cwd(), ".env") });
10
+
11
+ const packageDir = path.join(__dirname, "..");
12
+ const isWindows = process.platform === "win32";
13
+
14
+ // Ambiente virtual isolado no Temp do usuário
15
+ const venvDir = path.join(os.tmpdir(), "cli-yt-venv");
16
+
17
+ const pythonExecutable = isWindows
18
+ ? path.join(venvDir, "Scripts", "python.exe")
19
+ : path.join(venvDir, "bin", "python");
20
+
21
+ const pipExecutable = isWindows
22
+ ? path.join(venvDir, "Scripts", "pip.exe")
23
+ : path.join(venvDir, "bin", "pip");
24
+
25
+ // Tenta pegar do .env ou do ambiente do sistema
26
+ const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
27
+ const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
28
+
29
+ function checkPython() {
30
+ try {
31
+ const cmd = isWindows ? "where python" : "which python3 || which python";
32
+ execSync(cmd, { stdio: "ignore" });
33
+ } catch (e) {
34
+ console.error("❌ Erro: Python 3 não foi encontrado no sistema.");
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ function setupVenv() {
40
+ const pythonCmd = isWindows ? "python" : "python3";
41
+
42
+ if (!fs.existsSync(pythonExecutable)) {
43
+ console.log("⚙️ Criando ambiente virtual Python isolado...");
44
+ execSync(`${pythonCmd} -m venv "${venvDir}"`, { stdio: "inherit" });
45
+ }
46
+
47
+ const reqFile = path.join(packageDir, "requirements.txt");
48
+ if (fs.existsSync(reqFile)) {
49
+ console.log("📦 Verificando/Instalando dependências...");
50
+ execSync(`"${pipExecutable}" install -q -r "${reqFile}"`, { stdio: "inherit" });
51
+ }
52
+ }
53
+
54
+ function runPlayer() {
55
+ const mainPy = path.join(packageDir, "main.py");
56
+
57
+ // Repassa o ambiente e garante as chaves no process.env
58
+ const child = spawn(pythonExecutable, [mainPy], {
59
+ stdio: "inherit",
60
+ cwd: process.cwd(),
61
+ env: {
62
+ ...process.env,
63
+ GOOGLE_CLIENT_ID: CLIENT_ID || "",
64
+ GOOGLE_CLIENT_SECRET: CLIENT_SECRET || "",
65
+ },
66
+ });
67
+
68
+ child.on("exit", (code) => {
69
+ process.exit(code || 0);
70
+ });
71
+ }
72
+
73
+ checkPython();
74
+ setupVenv();
75
+ runPlayer();
@@ -0,0 +1 @@
1
+ {"installed":{"client_id":"50790974670-qshvlqkejhu0ksj76v0t0lpcq3krm593.apps.googleusercontent.com","project_id":"cli-yt-507511","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}
package/main.py ADDED
@@ -0,0 +1,682 @@
1
+ import json
2
+ import os
3
+ import socket
4
+ import sys
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from google.auth.transport.requests import Request
10
+ from google.oauth2.credentials import Credentials
11
+ from google_auth_oauthlib.flow import InstalledAppFlow
12
+ from googleapiclient.discovery import build
13
+ from googleapiclient.errors import HttpError
14
+
15
+ import av
16
+ import numpy as np
17
+ import sounddevice as sd
18
+ import yt_dlp
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
+
33
+ # Tenta carregar variáveis do .env se existir, sem obrigar o usuário a ter um
34
+ try:
35
+ from dotenv import load_dotenv
36
+ load_dotenv(Path.cwd() / ".env")
37
+ load_dotenv(Path(__file__).resolve().parent / ".env")
38
+ except ImportError:
39
+ pass
40
+
41
+ socket.setdefaulttimeout(15)
42
+
43
+ # Diretório para salvar o token do usuário na pasta de configurações do sistema
44
+ CONFIG_DIR = Path.home() / ".config" / "meu-player-tui"
45
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
46
+ TOKEN_FILE = CONFIG_DIR / "token.json"
47
+
48
+ SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
49
+ MUSIC_CATEGORY_ID = "10"
50
+
51
+ # Credenciais padrão da aplicação Desktop para o fluxo do usuário
52
+ DEFAULT_CLIENT_ID = os.environ.get(
53
+ "GOOGLE_CLIENT_ID",
54
+ "50790974670-qshvlqkejhu0ksj76v0t0lpcq3krm593.apps.googleusercontent.com" # Substitua pelas suas credenciais reais do GCP (Desktop App)
55
+ )
56
+ DEFAULT_CLIENT_SECRET = os.environ.get(
57
+ "GOOGLE_CLIENT_SECRET",
58
+ "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" # Substitua pelas suas credenciais reais do GCP (Desktop App)
59
+ )
60
+
61
+
62
+ def get_client_config():
63
+ """Retorna a configuração OAuth 2.0 padrão para aplicação desktop."""
64
+ return {
65
+ "installed": {
66
+ "client_id": DEFAULT_CLIENT_ID,
67
+ "client_secret": DEFAULT_CLIENT_SECRET,
68
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
69
+ "token_uri": "https://oauth2.googleapis.com/token",
70
+ "redirect_uris": ["http://localhost:8080/"],
71
+ }
72
+ }
73
+
74
+
75
+ def get_youtube_service(interactive=False):
76
+ """Obtém a conexão com a API do YouTube para ver as curtidas do usuário."""
77
+ creds = None
78
+
79
+ if TOKEN_FILE.exists():
80
+ try:
81
+ creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)
82
+ except Exception:
83
+ creds = None
84
+
85
+ if creds and creds.expired and creds.refresh_token:
86
+ try:
87
+ creds.refresh(Request())
88
+ with open(TOKEN_FILE, "w", encoding="utf-8") as token:
89
+ token.write(creds.to_json())
90
+ except Exception:
91
+ creds = None
92
+
93
+ if not creds or not creds.valid:
94
+ if not interactive:
95
+ return None
96
+
97
+ client_config = get_client_config()
98
+ flow = InstalledAppFlow.from_client_config(
99
+ client_config,
100
+ SCOPES,
101
+ redirect_uri="http://localhost:8080/",
102
+ )
103
+
104
+ creds = flow.run_local_server(
105
+ port=8080,
106
+ prompt="consent",
107
+ access_type="offline",
108
+ )
109
+
110
+ with open(TOKEN_FILE, "w", encoding="utf-8") as token:
111
+ token.write(creds.to_json())
112
+
113
+ return build("youtube", "v3", credentials=creds)
114
+
115
+
116
+ class MusicPlayerApp(App):
117
+ CSS = """
118
+ Screen {
119
+ layout: vertical;
120
+ }
121
+
122
+ #main {
123
+ height: 1fr;
124
+ }
125
+
126
+ #sidebar {
127
+ width: 44;
128
+ height: 100%;
129
+ background: $panel;
130
+ border-right: heavy $accent;
131
+ padding: 1;
132
+ }
133
+
134
+ #sidebar Input {
135
+ margin-bottom: 1;
136
+ }
137
+
138
+ #auth-buttons {
139
+ height: 3;
140
+ margin-bottom: 1;
141
+ }
142
+
143
+ #auth-buttons Button {
144
+ margin-right: 1;
145
+ }
146
+
147
+ #results_list {
148
+ height: 1fr;
149
+ border: solid $accent;
150
+ }
151
+
152
+ #btn_load_more {
153
+ width: 100%;
154
+ margin-top: 1;
155
+ }
156
+
157
+ #content {
158
+ width: 1fr;
159
+ height: 100%;
160
+ padding: 2;
161
+ align: center middle;
162
+ }
163
+
164
+ #now-playing {
165
+ text-align: center;
166
+ text-style: bold;
167
+ margin-bottom: 1;
168
+ }
169
+
170
+ #visualizer {
171
+ text-align: center;
172
+ color: $success;
173
+ height: 2;
174
+ margin-bottom: 1;
175
+ }
176
+
177
+ #progress-container {
178
+ height: 3;
179
+ align: center middle;
180
+ margin-bottom: 1;
181
+ }
182
+
183
+ #progress-container ProgressBar {
184
+ width: 1fr;
185
+ }
186
+
187
+ #volume-container {
188
+ height: 3;
189
+ align: center middle;
190
+ margin-bottom: 1;
191
+ }
192
+
193
+ #volume-container Button {
194
+ margin: 0 1;
195
+ }
196
+
197
+ #controls {
198
+ height: 3;
199
+ align: center middle;
200
+ }
201
+
202
+ #controls Button {
203
+ margin: 0 1;
204
+ }
205
+
206
+ #status {
207
+ margin-top: 1;
208
+ color: $text-muted;
209
+ }
210
+ """
211
+
212
+ BINDINGS = [
213
+ ("q", "quit", "Sair"),
214
+ ("space", "toggle_play", "Play/Pause"),
215
+ ("up", "volume_up", "Aumentar Vol"),
216
+ ("down", "volume_down", "Diminuir Vol"),
217
+ ]
218
+
219
+ def __init__(self):
220
+ super().__init__()
221
+ self.stream_thread = None
222
+ self.stop_event = threading.Event()
223
+ self.pause_event = threading.Event()
224
+ self.pause_event.set()
225
+
226
+ self.is_playing = False
227
+ self.raw_results = []
228
+ self.filtered_results = []
229
+ self.youtube_api = None
230
+
231
+ # Paginação e Busca
232
+ self.next_page_token = None
233
+ self.last_query = ""
234
+ self.last_source_type = None # 'search' ou 'liked'
235
+ self.is_loading_more = False
236
+
237
+ # Áudio
238
+ self.volume = 0.8
239
+ self.duration_seconds = 0
240
+ self.current_position_seconds = 0
241
+ self.seek_target_seconds = None
242
+
243
+ def compose(self) -> ComposeResult:
244
+ yield Header()
245
+
246
+ with Horizontal(id="main"):
247
+ 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")
265
+
266
+ 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")
290
+
291
+ yield Footer()
292
+
293
+ def on_mount(self) -> None:
294
+ def auto_auth():
295
+ self.youtube_api = get_youtube_service(interactive=False)
296
+ status = self.query_one("#status", Label)
297
+ if self.youtube_api:
298
+ self.call_from_thread(status.update, "[green]Sessão de usuário carregada com sucesso![/green]")
299
+
300
+ threading.Thread(target=auto_auth, daemon=True).start()
301
+
302
+ # --- FILTRAGEM E ATUALIZAÇÃO ---
303
+
304
+ def on_select_changed(self, event: Select.Changed) -> None:
305
+ if event.select.id == "filter_select":
306
+ self._apply_filter_and_update_ui()
307
+
308
+ def _apply_filter_and_update_ui(self) -> None:
309
+ filter_type = self.query_one("#filter_select", Select).value
310
+
311
+ if filter_type == "music":
312
+ self.filtered_results = [
313
+ item for item in self.raw_results if item.get("is_music")
314
+ ]
315
+ else:
316
+ self.filtered_results = list(self.raw_results)
317
+
318
+ list_view = self.query_one("#results_list", ListView)
319
+ list_view.clear()
320
+
321
+ for item in self.filtered_results:
322
+ tag = "🎵" if item.get("is_music") else "🎬"
323
+ list_view.append(ListItem(Label(f"{tag} {item['title']}")))
324
+
325
+ status = self.query_one("#status", Label)
326
+ status.update(f"[green]Exibindo {len(self.filtered_results)} item(ns).[/green]")
327
+
328
+ # --- AUTENTICAÇÃO E CURTIDAS COM PAGINAÇÃO ---
329
+
330
+ def _authenticate_youtube(self) -> None:
331
+ status = self.query_one("#status", Label)
332
+ status.update("[yellow]Abra o navegador para autorizar o acesso...[/yellow]")
333
+
334
+ try:
335
+ self.youtube_api = get_youtube_service(interactive=True)
336
+ self.call_from_thread(status.update, "[green]Login realizado com sucesso![/green]")
337
+ except Exception as e:
338
+ self.call_from_thread(status.update, f"[red]Erro na autenticação: {e}[/red]")
339
+
340
+ def _fetch_liked_videos(self, load_more=False) -> None:
341
+ status = self.query_one("#status", Label)
342
+ status.update("[yellow]Carregando músicas curtidas...[/yellow]")
343
+
344
+ def fetch_task():
345
+ try:
346
+ if not self.youtube_api:
347
+ self.youtube_api = get_youtube_service(interactive=True)
348
+
349
+ kwargs = {
350
+ "part": "snippet",
351
+ "myRating": "like",
352
+ "maxResults": 20,
353
+ }
354
+ if load_more and self.next_page_token:
355
+ kwargs["pageToken"] = self.next_page_token
356
+
357
+ request = self.youtube_api.videos().list(**kwargs)
358
+ response = request.execute()
359
+
360
+ items = response.get("items", [])
361
+ self.next_page_token = response.get("nextPageToken")
362
+
363
+ new_items = [
364
+ {
365
+ "title": item["snippet"]["title"],
366
+ "url": f"https://www.youtube.com/watch?v={item['id']}",
367
+ "is_music": item["snippet"].get("categoryId") == MUSIC_CATEGORY_ID,
368
+ }
369
+ for item in items
370
+ ]
371
+
372
+ if load_more:
373
+ self.raw_results.extend(new_items)
374
+ else:
375
+ self.raw_results = new_items
376
+
377
+ self.last_source_type = "liked"
378
+ self.is_loading_more = False
379
+ self.call_from_thread(self._apply_filter_and_update_ui)
380
+
381
+ except Exception as e:
382
+ self.is_loading_more = False
383
+ self.call_from_thread(status.update, f"[red]Erro ao carregar curtidas: {e}[/red]")
384
+
385
+ if not self.is_loading_more:
386
+ self.is_loading_more = True
387
+ threading.Thread(target=fetch_task, daemon=True).start()
388
+
389
+ # --- BUSCA COM YT-DLP E PAGINAÇÃO ---
390
+
391
+ def on_input_submitted(self, event: Input.Submitted) -> None:
392
+ query = event.value.strip()
393
+ if not query:
394
+ return
395
+
396
+ self.last_query = query
397
+ self.next_page_token = None
398
+ self.raw_results = []
399
+
400
+ status = self.query_one("#status", Label)
401
+ status.update("[yellow]Buscando no YouTube...[/yellow]")
402
+
403
+ threading.Thread(
404
+ target=self._search_youtube, args=(query, False), daemon=True
405
+ ).start()
406
+
407
+ def _search_youtube(self, query: str, load_more=False) -> None:
408
+ offset = len(self.raw_results) + 1 if load_more else 1
409
+
410
+ ydl_opts = {
411
+ "format": "bestaudio/best/best",
412
+ "quiet": True,
413
+ "playliststart": offset,
414
+ "playlistend": offset + 15,
415
+ "default_search": f"ytsearch{offset + 15}",
416
+ "noplaylist": True,
417
+ }
418
+
419
+ try:
420
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
421
+ info = ydl.extract_info(query, download=False)
422
+ entries = info.get("entries", [])
423
+
424
+ new_entries = [
425
+ {
426
+ "title": entry.get("title"),
427
+ "url": entry.get("webpage_url") or entry.get("url"),
428
+ "duration": entry.get("duration", 0),
429
+ "is_music": entry.get("categories") and "Music" in entry.get("categories") or True,
430
+ }
431
+ for entry in entries
432
+ if entry
433
+ ]
434
+
435
+ if load_more:
436
+ self.raw_results.extend(new_entries)
437
+ else:
438
+ self.raw_results = new_entries
439
+
440
+ self.last_source_type = "search"
441
+ self.is_loading_more = False
442
+ self.call_from_thread(self._apply_filter_and_update_ui)
443
+ except Exception as e:
444
+ self.is_loading_more = False
445
+ self.call_from_thread(
446
+ self.query_one("#status", Label).update,
447
+ f"[red]Erro na busca: {e}[/red]",
448
+ )
449
+
450
+ def _load_more_content(self) -> None:
451
+ if self.is_loading_more:
452
+ return
453
+
454
+ if self.last_source_type == "liked":
455
+ self._fetch_liked_videos(load_more=True)
456
+ elif self.last_source_type == "search" and self.last_query:
457
+ self.is_loading_more = True
458
+ threading.Thread(
459
+ target=self._search_youtube, args=(self.last_query, True), daemon=True
460
+ ).start()
461
+
462
+ # --- REPRODUÇÃO E ÁUDIO ---
463
+
464
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
465
+ index = event.list_view.index
466
+ if index is not None and index < len(self.filtered_results):
467
+ selected_track = self.filtered_results[index]
468
+ self._play_stream(selected_track)
469
+
470
+ if index >= len(self.filtered_results) - 2:
471
+ self._load_more_content()
472
+
473
+ def _play_stream(self, track: dict) -> None:
474
+ now_playing = self.query_one("#now-playing", Label)
475
+ status = self.query_one("#status", Label)
476
+
477
+ self._stop_audio()
478
+
479
+ now_playing.update(f"[bold cyan]Tocando:[/bold cyan] {track['title']}")
480
+ status.update("[yellow]Obtendo áudio...[/yellow]")
481
+
482
+ def stream_worker():
483
+ max_retries = 3
484
+ retry_count = 0
485
+
486
+ self.stop_event.clear()
487
+ self.pause_event.set()
488
+
489
+ while retry_count <= max_retries and not self.stop_event.is_set():
490
+ try:
491
+ ydl_opts = {
492
+ "format": "bestaudio/best",
493
+ "quiet": True,
494
+ "nocheckcertificate": True,
495
+ }
496
+
497
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
498
+ info = ydl.extract_info(track["url"], download=False)
499
+ direct_url = info["url"]
500
+ self.duration_seconds = info.get("duration", 0)
501
+
502
+ container_options = {
503
+ "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
504
+ "reconnect": "1",
505
+ "reconnect_streamed": "1",
506
+ "reconnect_delay_max": "5",
507
+ }
508
+
509
+ 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
+
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)
517
+
518
+ resampler = av.AudioResampler(format="fltp", layout="stereo", rate=sample_rate)
519
+
520
+ with sd.OutputStream(
521
+ samplerate=sample_rate, channels=2, dtype="float32"
522
+ ) as output_stream:
523
+ self.is_playing = True
524
+ self.call_from_thread(status.update, "[green]Reproduzindo ♪[/green]")
525
+
526
+ for packet in container.demux(audio_stream):
527
+ if self.stop_event.is_set():
528
+ break
529
+
530
+ if self.seek_target_seconds is not None:
531
+ target_pts = int(self.seek_target_seconds / time_base)
532
+ container.seek(target_pts, stream=audio_stream)
533
+ self.current_position_seconds = self.seek_target_seconds
534
+ self.seek_target_seconds = None
535
+ continue
536
+
537
+ for frame in packet.decode():
538
+ if self.stop_event.is_set():
539
+ break
540
+
541
+ self.pause_event.wait()
542
+
543
+ if frame.pts is not None:
544
+ self.current_position_seconds = frame.pts * time_base
545
+
546
+ resampled_frames = resampler.resample(frame)
547
+ if not resampled_frames:
548
+ continue
549
+
550
+ for r_frame in resampled_frames:
551
+ audio_array = r_frame.to_ndarray()
552
+
553
+ if audio_array.ndim == 1:
554
+ audio_array = np.vstack((audio_array, audio_array))
555
+
556
+ audio_array = audio_array * self.volume
557
+ audio_data = np.ascontiguousarray(audio_array.T, dtype=np.float32)
558
+
559
+ output_stream.write(audio_data)
560
+
561
+ rms = np.sqrt(np.mean(audio_data ** 2))
562
+ self.call_from_thread(self._update_playback_ui, rms)
563
+
564
+ break
565
+
566
+ except (av.FFmpegError, OSError, Exception) as e:
567
+ if self.stop_event.is_set():
568
+ break
569
+
570
+ retry_count += 1
571
+ if retry_count <= max_retries:
572
+ self.call_from_thread(
573
+ status.update,
574
+ f"[yellow]Conexão perdida. Reconectando ({retry_count}/{max_retries})...[/yellow]",
575
+ )
576
+ time.sleep(1)
577
+ else:
578
+ self.is_playing = False
579
+ self.call_from_thread(status.update, f"[red]Erro ao tocar: {e}[/red]")
580
+
581
+ self.is_playing = False
582
+ self.call_from_thread(self._reset_playback_ui)
583
+
584
+ self.stream_thread = threading.Thread(target=stream_worker, daemon=True)
585
+ self.stream_thread.start()
586
+
587
+ def _update_playback_ui(self, rms_volume: float) -> None:
588
+ curr_str = time.strftime("%M:%S", time.gmtime(self.current_position_seconds))
589
+ tot_str = time.strftime("%M:%S", time.gmtime(self.duration_seconds))
590
+
591
+ self.query_one("#time_current", Label).update(f"{curr_str} ")
592
+ self.query_one("#time_total", Label).update(f" {tot_str}")
593
+
594
+ progress_bar = self.query_one("#song_progress", ProgressBar)
595
+ if self.duration_seconds > 0:
596
+ progress_bar.progress = (self.current_position_seconds / self.duration_seconds) * 100
597
+
598
+ bars = [" ", " ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
599
+ level = min(int(rms_volume * 35), len(bars) - 1)
600
+ char = bars[level]
601
+
602
+ meter_str = f"░▒▓█ {char * 12} █▓▒░"
603
+ self.query_one("#visualizer", Label).update(meter_str)
604
+
605
+ def _reset_playback_ui(self) -> None:
606
+ self.query_one("#visualizer", Label).update("░░░░░░░░░░░░░░░░░░░░")
607
+ self.query_one("#song_progress", ProgressBar).progress = 0
608
+ self.query_one("#time_current", Label).update("00:00 ")
609
+ self.query_one("#time_total", Label).update(" 00:00")
610
+
611
+ # --- CONTROLES E ATALHOS ---
612
+
613
+ def _stop_audio(self) -> None:
614
+ self.stop_event.set()
615
+ self.pause_event.set()
616
+ if self.stream_thread and self.stream_thread.is_alive():
617
+ self.stream_thread.join(timeout=1.0)
618
+ self.is_playing = False
619
+
620
+ def action_toggle_play(self) -> None:
621
+ self.toggle_audio()
622
+
623
+ def action_volume_up(self) -> None:
624
+ self._adjust_volume(0.05)
625
+
626
+ def action_volume_down(self) -> None:
627
+ self._adjust_volume(-0.05)
628
+
629
+ def _adjust_volume(self, delta: float) -> None:
630
+ self.volume = max(0.0, min(1.0, self.volume + delta))
631
+ vol_pct = int(self.volume * 100)
632
+ self.query_one("#vol_label", Label).update(f" Volume: {vol_pct}% ")
633
+
634
+ def _seek(self, seconds_delta: float) -> None:
635
+ new_pos = max(0, self.current_position_seconds + seconds_delta)
636
+ if self.duration_seconds > 0:
637
+ new_pos = min(self.duration_seconds - 1, new_pos)
638
+ self.seek_target_seconds = new_pos
639
+
640
+ def on_button_pressed(self, event: Button.Pressed) -> None:
641
+ b_id = event.button.id
642
+ if b_id == "btn_toggle":
643
+ self.toggle_audio()
644
+ elif b_id == "btn_stop":
645
+ self._stop_audio()
646
+ self._reset_playback_ui()
647
+ self.query_one("#status", Label).update("Reprodução parada.")
648
+ self.query_one("#now-playing", Label).update("Nenhuma música selecionada")
649
+ elif b_id == "btn_vol_up":
650
+ self._adjust_volume(0.1)
651
+ elif b_id == "btn_vol_down":
652
+ self._adjust_volume(-0.1)
653
+ elif b_id == "btn_rewind":
654
+ self._seek(-10)
655
+ elif b_id == "btn_forward":
656
+ 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":
664
+ self._load_more_content()
665
+
666
+ def toggle_audio(self) -> None:
667
+ if not self.stream_thread or not self.stream_thread.is_alive():
668
+ return
669
+
670
+ if self.is_playing:
671
+ self.pause_event.clear()
672
+ self.is_playing = False
673
+ self.query_one("#status", Label).update("Pausado ⏸")
674
+ else:
675
+ self.pause_event.set()
676
+ self.is_playing = True
677
+ self.query_one("#status", Label).update("Reproduzindo ♪")
678
+
679
+
680
+ if __name__ == "__main__":
681
+ app = MusicPlayerApp()
682
+ app.run()
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "git-cli-yt",
3
+ "version": "1.1.3",
4
+ "description": "YouTube TUI Player executável via NPX",
5
+ "main": "bin/cli.js",
6
+ "bin": {
7
+ "meu-player-tui": "bin/cli.js"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "keywords": [
13
+ "tui",
14
+ "youtube",
15
+ "music",
16
+ "cli"
17
+ ],
18
+ "author": "Bruno Soares",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "dotenv": "^17.4.2"
22
+ }
23
+ }
@@ -0,0 +1,8 @@
1
+ textual>=0.50.0
2
+ yt-dlp>=2024.3.10
3
+ google-api-python-client>=2.100.0
4
+ google-auth-oauthlib>=1.2.0
5
+ av>=11.0.0
6
+ sounddevice>=0.4.6
7
+ numpy>=1.26.0
8
+ python-dotenv>=1.0.0