git-cli-yt 1.1.8 → 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.
- package/bin/cli.js +1 -1
- package/main.py +527 -144
- package/package.json +1 -1
package/bin/cli.js
CHANGED
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
|
|
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
|
|
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"
|
|
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
|
|
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(
|
|
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:
|
|
146
|
+
width: 28;
|
|
128
147
|
height: 100%;
|
|
129
|
-
background: $panel;
|
|
130
|
-
border-right:
|
|
131
|
-
padding: 1;
|
|
148
|
+
background: $youtube-panel-dark;
|
|
149
|
+
border-right: solid $youtube-border;
|
|
150
|
+
padding: 1 1;
|
|
132
151
|
}
|
|
133
152
|
|
|
134
|
-
#sidebar
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
#
|
|
144
|
-
|
|
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
|
-
|
|
227
|
+
background: $youtube-bg;
|
|
228
|
+
border: solid $youtube-border;
|
|
229
|
+
margin-top: 1;
|
|
150
230
|
}
|
|
151
231
|
|
|
152
|
-
#
|
|
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
|
-
#
|
|
158
|
-
width:
|
|
159
|
-
|
|
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
|
-
|
|
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: $
|
|
173
|
-
height: 2;
|
|
174
|
-
margin-bottom: 1;
|
|
288
|
+
color: $youtube-red;
|
|
175
289
|
}
|
|
176
290
|
|
|
177
291
|
#progress-container {
|
|
178
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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("
|
|
249
|
-
yield
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
yield Button("
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
yield
|
|
288
|
-
|
|
289
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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":
|
|
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(
|
|
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
|
-
|
|
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
|
-
"
|
|
414
|
-
"
|
|
415
|
-
"
|
|
416
|
-
"
|
|
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(
|
|
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("
|
|
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":
|
|
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,
|
|
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":
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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,
|
|
522
|
-
|
|
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(
|
|
862
|
+
self.call_from_thread(
|
|
863
|
+
status.update, "[green]Reproduzindo ♪[/green]"
|
|
864
|
+
)
|
|
525
865
|
|
|
526
|
-
|
|
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(
|
|
878
|
+
target_pts = int(
|
|
879
|
+
self.seek_target_seconds / time_base
|
|
880
|
+
)
|
|
532
881
|
container.seek(target_pts, stream=audio_stream)
|
|
533
|
-
|
|
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
|
-
|
|
887
|
+
resampler = av.AudioResampler(
|
|
888
|
+
format="fltp",
|
|
889
|
+
layout="stereo",
|
|
890
|
+
rate=sample_rate,
|
|
891
|
+
)
|
|
892
|
+
continue
|
|
542
893
|
|
|
543
|
-
|
|
544
|
-
self.current_position_seconds = frame.pts * time_base
|
|
894
|
+
self.pause_event.wait()
|
|
545
895
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
896
|
+
resampled_frames = resampler.resample(frame)
|
|
897
|
+
if not resampled_frames:
|
|
898
|
+
continue
|
|
549
899
|
|
|
550
|
-
|
|
551
|
-
|
|
900
|
+
for r_frame in resampled_frames:
|
|
901
|
+
if self.stop_event.is_set():
|
|
902
|
+
break
|
|
552
903
|
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
557
|
-
|
|
908
|
+
audio_array = audio_array * self.volume
|
|
909
|
+
audio_data = np.ascontiguousarray(audio_array.T, dtype=np.float32)
|
|
558
910
|
|
|
559
|
-
|
|
911
|
+
with pcm_lock:
|
|
912
|
+
pcm_buffer.extend(audio_data.tobytes())
|
|
560
913
|
|
|
561
|
-
|
|
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]
|
|
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(
|
|
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(
|
|
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(
|
|
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 = (
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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:
|