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.
- package/PRIVACY_POLICY.md +7 -0
- package/README.md +1 -0
- package/bin/cli.js +75 -0
- package/client_secret.json +1 -0
- package/main.py +682 -0
- package/package.json +23 -0
- package/requirements.txt +8 -0
|
@@ -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
|
+
}
|