git-cli-yt 1.2.0 → 2.0.1
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 +10 -0
- package/README.md +49 -1
- package/app.py +1322 -0
- package/audio/__init__.py +0 -0
- package/audio/audio_player.py +566 -0
- package/bin/cli.js +125 -66
- package/main.py +8 -1065
- package/mix/__init__.py +0 -0
- package/mix/mix_service.py +227 -0
- package/package.json +32 -29
- package/requirements.txt +10 -8
- package/utils/__init__.py +0 -0
- package/utils/helpers.py +37 -0
- package/youtube/__init__.py +0 -0
- package/youtube/auth.py +196 -0
- package/youtube/liked.py +121 -0
- package/youtube/playlist.py +139 -0
- package/youtube/search.py +362 -0
package/mix/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Geração de playlist relacionada à faixa âncora."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
import unicodedata
|
|
8
|
+
from difflib import SequenceMatcher
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from utils.helpers import extract_video_id
|
|
13
|
+
|
|
14
|
+
MIX_INITIAL_COUNT = 30
|
|
15
|
+
MIX_REFILL_COUNT = 10
|
|
16
|
+
_GENERIC_TITLE_TOKENS = frozenset(
|
|
17
|
+
{"song", "music", "video", "audio", "live", "radio", "mix", "hit", "hits"}
|
|
18
|
+
)
|
|
19
|
+
MIX_REFILL_THRESHOLD = 3
|
|
20
|
+
|
|
21
|
+
FetchResult = tuple[list[dict[str, Any]], Optional[str]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MixService:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
fetch_fn: Callable[[str, int, Optional[str]], FetchResult],
|
|
28
|
+
) -> None:
|
|
29
|
+
self.fetch_fn = fetch_fn
|
|
30
|
+
self.tracks: list[dict[str, Any]] = []
|
|
31
|
+
self.anchor: Optional[dict[str, Any]] = None
|
|
32
|
+
self.artist: Optional[str] = None
|
|
33
|
+
self.seen_ids: set[str] = set()
|
|
34
|
+
self.seen_sigs: set[tuple[str, str]] = set()
|
|
35
|
+
self.seen_titles: set[str] = set()
|
|
36
|
+
self._page_tokens: dict[str, str] = {}
|
|
37
|
+
self._exhausted_queries: set[str] = set()
|
|
38
|
+
|
|
39
|
+
def reset(self) -> None:
|
|
40
|
+
self.tracks.clear()
|
|
41
|
+
self.anchor = None
|
|
42
|
+
self.artist = None
|
|
43
|
+
self.seen_ids.clear()
|
|
44
|
+
self.seen_sigs.clear()
|
|
45
|
+
self._page_tokens.clear()
|
|
46
|
+
self.seen_titles.clear()
|
|
47
|
+
self._exhausted_queries.clear()
|
|
48
|
+
|
|
49
|
+
def start(
|
|
50
|
+
self,
|
|
51
|
+
track: dict[str, Any],
|
|
52
|
+
seed_tracks: Optional[list[dict[str, Any]]] = None,
|
|
53
|
+
) -> list[dict[str, Any]]:
|
|
54
|
+
"""Cria a Mix usando resultados já carregados antes da API."""
|
|
55
|
+
self.reset()
|
|
56
|
+
self.anchor = track
|
|
57
|
+
self.artist = str(
|
|
58
|
+
track.get("artist") or self._extract_artist(str(track.get("title", "")))
|
|
59
|
+
).strip() or None
|
|
60
|
+
self._add(track)
|
|
61
|
+
for candidate in seed_tracks or []:
|
|
62
|
+
if len(self.tracks) >= MIX_INITIAL_COUNT:
|
|
63
|
+
break
|
|
64
|
+
self._add(candidate)
|
|
65
|
+
self._generate_batch(MIX_INITIAL_COUNT - len(self.tracks))
|
|
66
|
+
return list(self.tracks)
|
|
67
|
+
|
|
68
|
+
def refill(self) -> list[dict[str, Any]]:
|
|
69
|
+
"""Adiciona até dez faixas relacionadas à Mix atual."""
|
|
70
|
+
if not self.anchor:
|
|
71
|
+
return []
|
|
72
|
+
previous_count = len(self.tracks)
|
|
73
|
+
self._generate_batch(MIX_REFILL_COUNT)
|
|
74
|
+
return self.tracks[previous_count:]
|
|
75
|
+
|
|
76
|
+
def next(self, current_track: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
|
77
|
+
if not self.tracks:
|
|
78
|
+
return None
|
|
79
|
+
if current_track is None:
|
|
80
|
+
return self.tracks[0]
|
|
81
|
+
|
|
82
|
+
current_id = self._id_of(current_track)
|
|
83
|
+
for index, track in enumerate(self.tracks):
|
|
84
|
+
matches = (
|
|
85
|
+
self._id_of(track) == current_id
|
|
86
|
+
if current_id
|
|
87
|
+
else track is current_track
|
|
88
|
+
)
|
|
89
|
+
if matches:
|
|
90
|
+
return self.tracks[index + 1] if index + 1 < len(self.tracks) else None
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def needs_refill(self, remaining: int) -> bool:
|
|
94
|
+
return bool(self.anchor) and remaining <= MIX_REFILL_THRESHOLD
|
|
95
|
+
|
|
96
|
+
def clear(self) -> None:
|
|
97
|
+
self.reset()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def total_tracks(self) -> int:
|
|
101
|
+
return len(self.tracks)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def anchor_track(self) -> Optional[dict[str, Any]]:
|
|
105
|
+
return self.anchor
|
|
106
|
+
|
|
107
|
+
def _generate_batch(self, count_to_add: int) -> None:
|
|
108
|
+
if count_to_add <= 0:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
generated = 0
|
|
112
|
+
for query in self._build_queries():
|
|
113
|
+
if generated >= count_to_add or query in self._exhausted_queries:
|
|
114
|
+
continue
|
|
115
|
+
while generated < count_to_add:
|
|
116
|
+
batch_size = min(15, max(1, (count_to_add - generated) * 2))
|
|
117
|
+
page_token = self._page_tokens.get(query)
|
|
118
|
+
tracks, next_page = self.fetch_fn(query, batch_size, page_token)
|
|
119
|
+
if next_page == page_token:
|
|
120
|
+
next_page = None
|
|
121
|
+
if next_page:
|
|
122
|
+
self._page_tokens[query] = next_page
|
|
123
|
+
else:
|
|
124
|
+
self._exhausted_queries.add(query)
|
|
125
|
+
for track in tracks:
|
|
126
|
+
if generated >= count_to_add:
|
|
127
|
+
break
|
|
128
|
+
if self._add(track):
|
|
129
|
+
generated += 1
|
|
130
|
+
if not next_page:
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
def _add(self, track: dict[str, Any]) -> bool:
|
|
134
|
+
video_id = self._id_of(track)
|
|
135
|
+
if not video_id or video_id in self.seen_ids:
|
|
136
|
+
return False
|
|
137
|
+
signature = (
|
|
138
|
+
str(track.get("title", "")).strip().casefold(),
|
|
139
|
+
str(track.get("artist", "")).strip().casefold(),
|
|
140
|
+
)
|
|
141
|
+
normalized_title = self._content_title(track)
|
|
142
|
+
if signature in self.seen_sigs or self._is_duplicate_title(normalized_title):
|
|
143
|
+
return False
|
|
144
|
+
self.tracks.append(track)
|
|
145
|
+
self.seen_ids.add(video_id)
|
|
146
|
+
self.seen_sigs.add(signature)
|
|
147
|
+
if normalized_title:
|
|
148
|
+
self.seen_titles.add(normalized_title)
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
def _content_title(self, track: dict[str, Any]) -> str:
|
|
152
|
+
normalized = _normalize_title(track.get("title", ""))
|
|
153
|
+
artist = _normalize_title(track.get("artist", "") or self.artist or "")
|
|
154
|
+
artist_tokens = set(artist.split())
|
|
155
|
+
if artist_tokens:
|
|
156
|
+
normalized = " ".join(
|
|
157
|
+
token for token in normalized.split() if token not in artist_tokens
|
|
158
|
+
)
|
|
159
|
+
return normalized or _normalize_title(track.get("title", ""))
|
|
160
|
+
|
|
161
|
+
def _is_duplicate_title(self, normalized_title: str) -> bool:
|
|
162
|
+
if not normalized_title:
|
|
163
|
+
return False
|
|
164
|
+
current_tokens = set(normalized_title.split())
|
|
165
|
+
for existing in self.seen_titles:
|
|
166
|
+
if normalized_title == existing:
|
|
167
|
+
return True
|
|
168
|
+
existing_tokens = set(existing.split())
|
|
169
|
+
if (
|
|
170
|
+
current_tokens.issubset(existing_tokens)
|
|
171
|
+
or existing_tokens.issubset(current_tokens)
|
|
172
|
+
) and any(
|
|
173
|
+
len(token) >= 5 and token not in _GENERIC_TITLE_TOKENS
|
|
174
|
+
for token in current_tokens & existing_tokens
|
|
175
|
+
):
|
|
176
|
+
return True
|
|
177
|
+
if SequenceMatcher(None, normalized_title, existing).ratio() >= 0.84:
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def _id_of(self, track: dict[str, Any]) -> Optional[str]:
|
|
182
|
+
return str(track.get("id") or extract_video_id(str(track.get("url", "")))) or None
|
|
183
|
+
|
|
184
|
+
def _build_queries(self) -> list[str]:
|
|
185
|
+
if not self.anchor:
|
|
186
|
+
return []
|
|
187
|
+
title = str(self.anchor.get("title", "")).strip()
|
|
188
|
+
artist = self.artist
|
|
189
|
+
if artist:
|
|
190
|
+
return [
|
|
191
|
+
f"{artist} {title}",
|
|
192
|
+
f"{artist} música",
|
|
193
|
+
f"{artist} hits",
|
|
194
|
+
f"{artist} melhores",
|
|
195
|
+
]
|
|
196
|
+
return [title, f"música {title}", f"hit {title}"]
|
|
197
|
+
|
|
198
|
+
def _extract_artist(self, title: str) -> str:
|
|
199
|
+
cleaned = re.sub(
|
|
200
|
+
r"\s*[\(\[](?:official|video|lyrics?|audio|hd|4k|remaster.*?)[\)\]]",
|
|
201
|
+
"",
|
|
202
|
+
title,
|
|
203
|
+
flags=re.IGNORECASE,
|
|
204
|
+
)
|
|
205
|
+
for separator in (" - ", " – ", " — "):
|
|
206
|
+
if separator in cleaned:
|
|
207
|
+
return cleaned.split(separator, 1)[0].strip()
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _normalize_title(value: Any) -> str:
|
|
212
|
+
text = html.unescape(str(value or ""))
|
|
213
|
+
text = unicodedata.normalize("NFKD", text)
|
|
214
|
+
text = "".join(char for char in text if not unicodedata.combining(char))
|
|
215
|
+
text = re.sub(r"[\(\[\{].*?[\)\]\}]", " ", text)
|
|
216
|
+
text = re.sub(
|
|
217
|
+
r"\b(?:official(?: music)?(?: video)?|music video|lyrics?|audio|"
|
|
218
|
+
r"visualizer|hd|4k|remaster(?:ed)?|full album|with lyrics|live|"
|
|
219
|
+
r"cover|remix|acoustic|instrumental|karaoke|sped up|slowed|"
|
|
220
|
+
r"part\s*\d+|volume\s*\d+)\b",
|
|
221
|
+
" ",
|
|
222
|
+
text,
|
|
223
|
+
flags=re.IGNORECASE,
|
|
224
|
+
)
|
|
225
|
+
text = re.sub(r"\b(?:feat|ft|featuring)\.?\s+.*$", " ", text, flags=re.IGNORECASE)
|
|
226
|
+
text = re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE)
|
|
227
|
+
return " ".join(text.casefold().split())
|
package/package.json
CHANGED
|
@@ -1,29 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "git-cli-yt",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "YouTube TUI Player executável via NPX",
|
|
5
|
-
"main": "bin/cli.js",
|
|
6
|
-
"bin": {
|
|
7
|
-
"git-cli-yt": "bin/cli.js",
|
|
8
|
-
"meu-player-tui": "bin/cli.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"bin/cli.js",
|
|
12
|
-
"main.py",
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "git-cli-yt",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "YouTube TUI Player V2 executável via Python ou NPX",
|
|
5
|
+
"main": "bin/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-cli-yt": "bin/cli.js",
|
|
8
|
+
"meu-player-tui": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/cli.js",
|
|
12
|
+
"main.py",
|
|
13
|
+
"app.py",
|
|
14
|
+
"audio/*.py",
|
|
15
|
+
"youtube/*.py",
|
|
16
|
+
"mix/*.py",
|
|
17
|
+
"utils/*.py",
|
|
18
|
+
"requirements.txt",
|
|
19
|
+
"PRIVACY_POLICY.md"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"tui",
|
|
26
|
+
"youtube",
|
|
27
|
+
"music",
|
|
28
|
+
"cli"
|
|
29
|
+
],
|
|
30
|
+
"author": "Bruno Soares",
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|
package/requirements.txt
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
textual>=0.
|
|
2
|
-
|
|
3
|
-
google-api-python-client>=2.100.0
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
textual>=4.0.0
|
|
2
|
+
rich>=14.0.0
|
|
3
|
+
google-api-python-client>=2.100.0
|
|
4
|
+
requests>=2.31.0
|
|
5
|
+
google-auth-oauthlib>=1.2.0
|
|
6
|
+
av>=11.0.0
|
|
7
|
+
sounddevice>=0.4.6
|
|
8
|
+
numpy>=1.26.0
|
|
9
|
+
python-dotenv>=1.0.0
|
|
10
|
+
yt-dlp>=2024.6.11
|
|
File without changes
|
package/utils/helpers.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Utilitários compartilhados entre módulos."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_time(seconds: Optional[float]) -> str:
|
|
8
|
+
"""Converte segundos em MM:SS (ou HH:MM:SS se > 1h)."""
|
|
9
|
+
if seconds is None:
|
|
10
|
+
return "00:00"
|
|
11
|
+
s = max(0, int(seconds))
|
|
12
|
+
h, s = divmod(s, 3600)
|
|
13
|
+
m, s = divmod(s, 60)
|
|
14
|
+
if h > 0:
|
|
15
|
+
return f"{h}:{m:02d}:{s:02d}"
|
|
16
|
+
return f"{m:02d}:{s:02d}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_video_id(url: str) -> Optional[str]:
|
|
20
|
+
"""Extrai o ID de vídeo de uma URL do YouTube."""
|
|
21
|
+
patterns = [
|
|
22
|
+
r"youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})",
|
|
23
|
+
r"youtube\.com/embed/([a-zA-Z0-9_-]{11})",
|
|
24
|
+
r"youtube\.com/shorts/([a-zA-Z0-9_-]{11})",
|
|
25
|
+
r"youtu\.be/([a-zA-Z0-9_-]{11})",
|
|
26
|
+
r"^([a-zA-Z0-9_-]{11})$",
|
|
27
|
+
]
|
|
28
|
+
for pattern in patterns:
|
|
29
|
+
match = re.search(pattern, url)
|
|
30
|
+
if match:
|
|
31
|
+
return match.group(1)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_related_url(video_id: str) -> str:
|
|
36
|
+
"""URL de busca relacionada usada pela Mix."""
|
|
37
|
+
return f"https://www.youtube.com/watch?v={video_id}&list=related"
|
|
File without changes
|
package/youtube/auth.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Autenticação OAuth 2.0 e acesso à YouTube Data API v3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional, cast
|
|
9
|
+
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
from google.auth.exceptions import GoogleAuthError
|
|
12
|
+
from google.auth.transport.requests import Request
|
|
13
|
+
from google.oauth2.credentials import Credentials
|
|
14
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
15
|
+
from googleapiclient.discovery import build
|
|
16
|
+
from googleapiclient.errors import HttpError
|
|
17
|
+
|
|
18
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
19
|
+
CONFIG_DIR = Path.home() / ".config" / "meu-player-tui"
|
|
20
|
+
TOKEN_FILE = CONFIG_DIR / "token.json"
|
|
21
|
+
CLIENT_SECRET_FILE = PROJECT_ROOT / "client_secret.json"
|
|
22
|
+
SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
|
|
23
|
+
_CHANNEL_INFO: Optional[dict[str, str]] = None
|
|
24
|
+
|
|
25
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
load_dotenv(PROJECT_ROOT / ".env")
|
|
27
|
+
load_dotenv(Path.cwd() / ".env")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OAuthConfigurationError(RuntimeError):
|
|
31
|
+
"""As credenciais do aplicativo OAuth não estão configuradas."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_api_key() -> str:
|
|
35
|
+
"""Retorna a chave opcional para busca pública."""
|
|
36
|
+
return (
|
|
37
|
+
os.environ.get("YOUTUBE_API_KEY", "")
|
|
38
|
+
or os.environ.get("GOOGLE_API_KEY", "")
|
|
39
|
+
).strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_client_config() -> dict[str, dict[str, Any]]:
|
|
43
|
+
"""Carrega o OAuth client do ambiente ou de ``client_secret.json``."""
|
|
44
|
+
client_id = (
|
|
45
|
+
os.environ.get("YOUTUBE_CLIENT_ID", "")
|
|
46
|
+
or os.environ.get("GOOGLE_CLIENT_ID", "")
|
|
47
|
+
).strip()
|
|
48
|
+
client_secret = (
|
|
49
|
+
os.environ.get("YOUTUBE_CLIENT_SECRET", "")
|
|
50
|
+
or os.environ.get("GOOGLE_CLIENT_SECRET", "")
|
|
51
|
+
).strip()
|
|
52
|
+
|
|
53
|
+
if not client_id or not client_secret:
|
|
54
|
+
file_config = _read_client_secret_file()
|
|
55
|
+
client_id = client_id or file_config.get("client_id", "")
|
|
56
|
+
client_secret = client_secret or file_config.get("client_secret", "")
|
|
57
|
+
|
|
58
|
+
if not client_id or not client_secret:
|
|
59
|
+
raise OAuthConfigurationError(
|
|
60
|
+
"Configure YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET no arquivo .env."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"installed": {
|
|
65
|
+
"client_id": client_id,
|
|
66
|
+
"client_secret": client_secret,
|
|
67
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
68
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
69
|
+
"redirect_uris": ["http://localhost"],
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_credentials(interactive: bool = False) -> Optional[Credentials]:
|
|
75
|
+
"""Carrega, renova ou cria credenciais OAuth."""
|
|
76
|
+
credentials = _load_saved_credentials()
|
|
77
|
+
|
|
78
|
+
if credentials and credentials.expired and credentials.refresh_token:
|
|
79
|
+
try:
|
|
80
|
+
credentials.refresh(Request())
|
|
81
|
+
_save_token(credentials)
|
|
82
|
+
except (GoogleAuthError, OSError):
|
|
83
|
+
credentials = None
|
|
84
|
+
|
|
85
|
+
if credentials and credentials.valid:
|
|
86
|
+
return credentials
|
|
87
|
+
if not interactive:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
flow = InstalledAppFlow.from_client_config(get_client_config(), SCOPES)
|
|
91
|
+
credentials = cast(
|
|
92
|
+
Credentials,
|
|
93
|
+
flow.run_local_server(
|
|
94
|
+
host="localhost",
|
|
95
|
+
port=8080,
|
|
96
|
+
authorization_prompt_message="Abra o navegador para autorizar o acesso.",
|
|
97
|
+
success_message="Login concluído. Você já pode fechar esta janela.",
|
|
98
|
+
open_browser=True,
|
|
99
|
+
access_type="offline",
|
|
100
|
+
prompt="consent",
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
_save_token(credentials)
|
|
104
|
+
return credentials
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_youtube_service(interactive: bool = False) -> Any:
|
|
108
|
+
"""Obtém o cliente da YouTube Data API."""
|
|
109
|
+
credentials = get_credentials(interactive=interactive)
|
|
110
|
+
if not credentials or not credentials.valid:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
return build("youtube", "v3", credentials=credentials)
|
|
114
|
+
except HttpError:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_logged_in() -> bool:
|
|
119
|
+
"""Verifica se há credenciais válidas sem abrir o navegador."""
|
|
120
|
+
return get_credentials(interactive=False) is not None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_channel_info(refresh: bool = False) -> Optional[dict[str, str]]:
|
|
124
|
+
"""Retorna ID e título do canal autenticado, com cache local."""
|
|
125
|
+
global _CHANNEL_INFO
|
|
126
|
+
if _CHANNEL_INFO is not None and not refresh:
|
|
127
|
+
return dict(_CHANNEL_INFO)
|
|
128
|
+
|
|
129
|
+
service = get_youtube_service(interactive=False)
|
|
130
|
+
if service is None:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
response = service.channels().list(part="snippet", mine=True).execute()
|
|
134
|
+
items = response.get("items", [])
|
|
135
|
+
if not items:
|
|
136
|
+
return None
|
|
137
|
+
item = items[0]
|
|
138
|
+
channel_id = str(item.get("id", "")).strip()
|
|
139
|
+
title = str(item.get("snippet", {}).get("title", "")).strip()
|
|
140
|
+
if not channel_id:
|
|
141
|
+
return None
|
|
142
|
+
_CHANNEL_INFO = {"id": channel_id, "title": title}
|
|
143
|
+
return dict(_CHANNEL_INFO)
|
|
144
|
+
except HttpError:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_account_name() -> Optional[str]:
|
|
149
|
+
"""Obtém o nome do canal autenticado."""
|
|
150
|
+
channel = get_channel_info()
|
|
151
|
+
return channel.get("title") if channel else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def clear_session() -> None:
|
|
155
|
+
"""Remove somente o token OAuth local."""
|
|
156
|
+
global _CHANNEL_INFO
|
|
157
|
+
TOKEN_FILE.unlink(missing_ok=True)
|
|
158
|
+
_CHANNEL_INFO = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def switch_account() -> Any:
|
|
162
|
+
"""Limpa a sessão local e inicia outro login."""
|
|
163
|
+
clear_session()
|
|
164
|
+
return get_youtube_service(interactive=True)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _load_saved_credentials() -> Optional[Credentials]:
|
|
168
|
+
if not TOKEN_FILE.exists():
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
return Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)
|
|
172
|
+
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _save_token(credentials: Credentials) -> None:
|
|
177
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
TOKEN_FILE.write_text(credentials.to_json(), encoding="utf-8")
|
|
179
|
+
try:
|
|
180
|
+
TOKEN_FILE.chmod(0o600)
|
|
181
|
+
except OSError:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _read_client_secret_file() -> dict[str, str]:
|
|
186
|
+
if not CLIENT_SECRET_FILE.exists():
|
|
187
|
+
return {}
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(CLIENT_SECRET_FILE.read_text(encoding="utf-8"))
|
|
190
|
+
installed = data.get("installed") or data.get("web") or {}
|
|
191
|
+
return {
|
|
192
|
+
"client_id": str(installed.get("client_id", "")),
|
|
193
|
+
"client_secret": str(installed.get("client_secret", "")),
|
|
194
|
+
}
|
|
195
|
+
except (OSError, ValueError, TypeError):
|
|
196
|
+
return {}
|
package/youtube/liked.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Lista paginada de vídeos curtidos na categoria Música do YouTube."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from googleapiclient.errors import HttpError
|
|
8
|
+
|
|
9
|
+
MUSIC_CATEGORY_ID = "10"
|
|
10
|
+
_LIKED_PAGE_SIZE = 50
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LikedError(RuntimeError):
|
|
14
|
+
"""Erro seguro e legível ao consultar os vídeos curtidos."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_liked(
|
|
18
|
+
youtube_service: Any,
|
|
19
|
+
page_token: Optional[str] = None,
|
|
20
|
+
max_results: int = 20,
|
|
21
|
+
) -> tuple[list[dict[str, Any]], Optional[str]]:
|
|
22
|
+
"""Retorna apenas curtidas da categoria Música e o próximo token."""
|
|
23
|
+
if youtube_service is None:
|
|
24
|
+
return [], None
|
|
25
|
+
|
|
26
|
+
target = max(1, min(int(max_results), 50))
|
|
27
|
+
results: list[dict[str, Any]] = []
|
|
28
|
+
next_token = page_token
|
|
29
|
+
|
|
30
|
+
while len(results) < target:
|
|
31
|
+
requested_token = next_token
|
|
32
|
+
kwargs: dict[str, Any] = {
|
|
33
|
+
"part": "snippet,contentDetails",
|
|
34
|
+
"myRating": "like",
|
|
35
|
+
"maxResults": _LIKED_PAGE_SIZE,
|
|
36
|
+
}
|
|
37
|
+
if next_token:
|
|
38
|
+
kwargs["pageToken"] = next_token
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
response = youtube_service.videos().list(**kwargs).execute()
|
|
42
|
+
except HttpError as exc:
|
|
43
|
+
raise _liked_error(exc) from exc
|
|
44
|
+
|
|
45
|
+
for item in response.get("items", []):
|
|
46
|
+
snippet = item.get("snippet", {})
|
|
47
|
+
if str(snippet.get("categoryId", "")) != MUSIC_CATEGORY_ID:
|
|
48
|
+
continue
|
|
49
|
+
video_id = str(item.get("id", ""))
|
|
50
|
+
if not video_id:
|
|
51
|
+
continue
|
|
52
|
+
raw_artist = str(
|
|
53
|
+
snippet.get("videoOwnerChannelTitle")
|
|
54
|
+
or snippet.get("channelTitle")
|
|
55
|
+
or ""
|
|
56
|
+
)
|
|
57
|
+
results.append(
|
|
58
|
+
{
|
|
59
|
+
"id": video_id,
|
|
60
|
+
"title": snippet.get("title", "Sem título"),
|
|
61
|
+
"url": f"https://www.youtube.com/watch?v={video_id}",
|
|
62
|
+
"duration": _duration_seconds(
|
|
63
|
+
item.get("contentDetails", {}).get("duration", "")
|
|
64
|
+
),
|
|
65
|
+
"is_music": True,
|
|
66
|
+
"artist": _clean_artist(raw_artist),
|
|
67
|
+
"channelTitle": raw_artist,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
if len(results) >= target:
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
response_token = response.get("nextPageToken")
|
|
74
|
+
if response_token and str(response_token) == requested_token:
|
|
75
|
+
next_token = None
|
|
76
|
+
break
|
|
77
|
+
next_token = str(response_token) if response_token else None
|
|
78
|
+
if next_token is None:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
return results, next_token
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _liked_error(error: HttpError) -> LikedError:
|
|
85
|
+
status = getattr(getattr(error, "resp", None), "status", None)
|
|
86
|
+
if status == 401:
|
|
87
|
+
return LikedError("A sessão expirou. Entre na conta novamente.")
|
|
88
|
+
if status == 403:
|
|
89
|
+
return LikedError("A API não retornou as curtidas para esta conta ou projeto.")
|
|
90
|
+
if status:
|
|
91
|
+
return LikedError(f"A API do YouTube respondeu com HTTP {status}.")
|
|
92
|
+
return LikedError("Não foi possível consultar as curtidas.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _clean_artist(name: str) -> Optional[str]:
|
|
96
|
+
"""Remove marcadores comuns de canais musicais."""
|
|
97
|
+
cleaned = name.strip()
|
|
98
|
+
for suffix in (" - Topic", "VEVO", " Official", " Canal Oficial"):
|
|
99
|
+
if cleaned.endswith(suffix):
|
|
100
|
+
cleaned = cleaned[: -len(suffix)].strip()
|
|
101
|
+
return cleaned or None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _duration_seconds(value: Any) -> float:
|
|
105
|
+
if not isinstance(value, str) or not value.startswith("P"):
|
|
106
|
+
return 0.0
|
|
107
|
+
try:
|
|
108
|
+
time_part = value[1:].split("T", 1)[1]
|
|
109
|
+
days = int(time_part.split("D", 1)[0]) if "D" in time_part else 0
|
|
110
|
+
if "D" in time_part:
|
|
111
|
+
time_part = time_part.split("D", 1)[1]
|
|
112
|
+
hours = int(time_part.split("H", 1)[0]) if "H" in time_part else 0
|
|
113
|
+
if "H" in time_part:
|
|
114
|
+
time_part = time_part.split("H", 1)[1]
|
|
115
|
+
minutes = int(time_part.split("M", 1)[0]) if "M" in time_part else 0
|
|
116
|
+
time_part = time_part.split("M", 1)[1] if "M" in time_part else time_part
|
|
117
|
+
return days * 86400 + hours * 3600 + minutes * 60 + float(
|
|
118
|
+
time_part.rstrip("S")
|
|
119
|
+
)
|
|
120
|
+
except (IndexError, TypeError, ValueError):
|
|
121
|
+
return 0.0
|