git-cli-yt 1.1.9 → 2.0.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/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 +105 -67
- package/main.py +8 -682
- 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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Listagem de playlists do YouTube via yt-dlp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import yt_dlp
|
|
9
|
+
|
|
10
|
+
from youtube.search import normalize_ytdlp_entry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlaylistError(RuntimeError):
|
|
14
|
+
"""Erro ao enumerar uma playlist."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fetch_user_playlists(
|
|
19
|
+
channel_id: str,
|
|
20
|
+
cookies_file: Optional[str] = None,
|
|
21
|
+
) -> list[dict[str, Any]]:
|
|
22
|
+
"""Retorna Curtidas primeiro e depois as playlists do canal."""
|
|
23
|
+
channel_id = channel_id.strip()
|
|
24
|
+
if not channel_id:
|
|
25
|
+
raise PlaylistError("Não foi possível identificar o canal do usuário.")
|
|
26
|
+
|
|
27
|
+
options: Any = {
|
|
28
|
+
"quiet": True,
|
|
29
|
+
"no_warnings": True,
|
|
30
|
+
"skip_download": True,
|
|
31
|
+
"extract_flat": True,
|
|
32
|
+
}
|
|
33
|
+
cookie_path = Path(cookies_file) if cookies_file else None
|
|
34
|
+
if cookie_path and cookie_path.exists():
|
|
35
|
+
options["cookiefile"] = str(cookie_path)
|
|
36
|
+
|
|
37
|
+
url = f"https://www.youtube.com/channel/{channel_id}/playlists"
|
|
38
|
+
try:
|
|
39
|
+
with yt_dlp.YoutubeDL(options) as downloader:
|
|
40
|
+
info = downloader.extract_info(url, download=False)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
raise PlaylistError("Não foi possível listar as playlists do usuário.") from exc
|
|
43
|
+
|
|
44
|
+
results: list[dict[str, Any]] = [
|
|
45
|
+
{
|
|
46
|
+
"id": "liked",
|
|
47
|
+
"kind": "liked",
|
|
48
|
+
"title": "Curtidas",
|
|
49
|
+
"url": "",
|
|
50
|
+
"count": None,
|
|
51
|
+
"thumbnail": "",
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
for entry in (info.get("entries") if isinstance(info, dict) else None) or []:
|
|
55
|
+
if not isinstance(entry, dict):
|
|
56
|
+
continue
|
|
57
|
+
playlist_id = str(entry.get("id", "")).strip()
|
|
58
|
+
playlist_url = str(
|
|
59
|
+
entry.get("webpage_url")
|
|
60
|
+
or entry.get("url")
|
|
61
|
+
or (f"https://www.youtube.com/playlist?list={playlist_id}" if playlist_id else "")
|
|
62
|
+
)
|
|
63
|
+
title = str(entry.get("title") or "Playlist sem título").strip()
|
|
64
|
+
if not playlist_id and not playlist_url:
|
|
65
|
+
continue
|
|
66
|
+
thumbnails = entry.get("thumbnails") or []
|
|
67
|
+
thumbnail = max(
|
|
68
|
+
thumbnails,
|
|
69
|
+
key=lambda item: item.get("width", 0) if isinstance(item, dict) else 0,
|
|
70
|
+
default={},
|
|
71
|
+
)
|
|
72
|
+
results.append(
|
|
73
|
+
{
|
|
74
|
+
"id": playlist_id or playlist_url,
|
|
75
|
+
"kind": "playlist",
|
|
76
|
+
"title": title,
|
|
77
|
+
"url": playlist_url,
|
|
78
|
+
"count": entry.get("playlist_count"),
|
|
79
|
+
"thumbnail": thumbnail.get("url", "")
|
|
80
|
+
if isinstance(thumbnail, dict)
|
|
81
|
+
else "",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
def fetch_playlist(
|
|
87
|
+
url: str,
|
|
88
|
+
count: int = 50,
|
|
89
|
+
page_token: Optional[str] = None,
|
|
90
|
+
cookies_file: Optional[str] = None,
|
|
91
|
+
) -> tuple[list[dict[str, Any]], Optional[str]]:
|
|
92
|
+
"""Retorna uma página de uma playlist, usando ``extract_flat``.
|
|
93
|
+
|
|
94
|
+
``cookies_file`` é opcional e só é necessário para playlists privadas ou
|
|
95
|
+
playlists pessoais, como a lista de vídeos curtidos.
|
|
96
|
+
"""
|
|
97
|
+
if not url.strip():
|
|
98
|
+
raise PlaylistError("Informe a URL da playlist.")
|
|
99
|
+
limit = max(1, min(int(count), 50))
|
|
100
|
+
offset = _offset(page_token)
|
|
101
|
+
options: Any = {
|
|
102
|
+
"quiet": True,
|
|
103
|
+
"no_warnings": True,
|
|
104
|
+
"skip_download": True,
|
|
105
|
+
"extract_flat": True,
|
|
106
|
+
"playliststart": offset,
|
|
107
|
+
"playlistend": offset + limit,
|
|
108
|
+
}
|
|
109
|
+
cookie_path = Path(cookies_file) if cookies_file else None
|
|
110
|
+
if cookie_path and cookie_path.exists():
|
|
111
|
+
options["cookiefile"] = str(cookie_path)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
with yt_dlp.YoutubeDL(options) as downloader:
|
|
115
|
+
info = downloader.extract_info(url, download=False)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise PlaylistError("Não foi possível ler a playlist com yt-dlp.") from exc
|
|
118
|
+
|
|
119
|
+
entries = info.get("entries") if isinstance(info, dict) else None
|
|
120
|
+
results = [
|
|
121
|
+
normalized
|
|
122
|
+
for entry in (entries or [])
|
|
123
|
+
if (normalized := normalize_ytdlp_entry(entry)) is not None
|
|
124
|
+
]
|
|
125
|
+
total = info.get("playlist_count") if isinstance(info, dict) else None
|
|
126
|
+
has_next = isinstance(total, int) and offset + limit < total
|
|
127
|
+
if total is None:
|
|
128
|
+
has_next = len(results) >= limit
|
|
129
|
+
next_token = str(offset + limit) if has_next else None
|
|
130
|
+
return results, next_token
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _offset(page_token: Optional[str]) -> int:
|
|
134
|
+
if not page_token:
|
|
135
|
+
return 0
|
|
136
|
+
try:
|
|
137
|
+
return max(0, int(page_token))
|
|
138
|
+
except ValueError:
|
|
139
|
+
return 0
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Busca paginada de vídeos usando a YouTube Data API v3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
import yt_dlp
|
|
10
|
+
from googleapiclient.errors import HttpError
|
|
11
|
+
|
|
12
|
+
from youtube import auth
|
|
13
|
+
|
|
14
|
+
_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
|
|
15
|
+
_VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
|
|
16
|
+
_MUSIC_CATEGORY_ID = "10"
|
|
17
|
+
_YTDLP_CACHE: dict[str, list[dict[str, Any]]] = {}
|
|
18
|
+
_YTDLP_MAX_RESULTS = 50
|
|
19
|
+
_NON_MUSIC_MARKERS = (
|
|
20
|
+
"notícia",
|
|
21
|
+
"podcast",
|
|
22
|
+
"gameplay",
|
|
23
|
+
"tutorial",
|
|
24
|
+
"aula",
|
|
25
|
+
"vlog",
|
|
26
|
+
"entrevista",
|
|
27
|
+
"shorts",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SearchError(RuntimeError):
|
|
32
|
+
"""Erro seguro e legível para falhas da busca."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_page(
|
|
36
|
+
query: str,
|
|
37
|
+
count: int = 15,
|
|
38
|
+
page_token: Optional[str] = None,
|
|
39
|
+
) -> tuple[list[dict[str, Any]], Optional[str]]:
|
|
40
|
+
"""Busca uma página; usa Data API e cai para yt-dlp quando necessário."""
|
|
41
|
+
query = query.strip()
|
|
42
|
+
if not query:
|
|
43
|
+
raise SearchError("Informe um termo de busca.")
|
|
44
|
+
|
|
45
|
+
count = max(1, min(int(count), 50))
|
|
46
|
+
service = auth.get_youtube_service(interactive=False)
|
|
47
|
+
if service is not None:
|
|
48
|
+
try:
|
|
49
|
+
response = _search_oauth(service, query, count, page_token)
|
|
50
|
+
results = _build_results(response, service=service)
|
|
51
|
+
if results:
|
|
52
|
+
return results, _next_page_token(response)
|
|
53
|
+
except HttpError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
api_key = auth.get_api_key()
|
|
57
|
+
if api_key:
|
|
58
|
+
try:
|
|
59
|
+
response = _search_with_key(query, count, page_token, api_key)
|
|
60
|
+
results = _build_results(response, api_key=api_key)
|
|
61
|
+
if results:
|
|
62
|
+
return results, _next_page_token(response)
|
|
63
|
+
except (requests.RequestException, ValueError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
return _fetch_ytdlp_page(query, count, page_token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def search(query: str, count: int = 15) -> Iterator[dict[str, Any]]:
|
|
70
|
+
"""Interface em generator para compatibilidade com consumidores simples."""
|
|
71
|
+
results, _ = fetch_page(query, count=count)
|
|
72
|
+
yield from results
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _next_page_token(response: dict[str, Any]) -> Optional[str]:
|
|
76
|
+
token = response.get("nextPageToken")
|
|
77
|
+
return str(token) if token else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fetch_ytdlp_page(
|
|
81
|
+
query: str,
|
|
82
|
+
count: int,
|
|
83
|
+
page_token: Optional[str],
|
|
84
|
+
) -> tuple[list[dict[str, Any]], Optional[str]]:
|
|
85
|
+
offset = _ytdlp_offset(page_token)
|
|
86
|
+
if offset >= _YTDLP_MAX_RESULTS:
|
|
87
|
+
return [], None
|
|
88
|
+
|
|
89
|
+
cache_key = query.casefold()
|
|
90
|
+
cached = _YTDLP_CACHE.get(cache_key, [])
|
|
91
|
+
required = min(offset + count, _YTDLP_MAX_RESULTS)
|
|
92
|
+
if len(cached) < required:
|
|
93
|
+
request_limit = min(required + count, _YTDLP_MAX_RESULTS)
|
|
94
|
+
try:
|
|
95
|
+
options = {
|
|
96
|
+
"quiet": True,
|
|
97
|
+
"no_warnings": True,
|
|
98
|
+
"skip_download": True,
|
|
99
|
+
"extract_flat": True,
|
|
100
|
+
}
|
|
101
|
+
with yt_dlp.YoutubeDL(options) as downloader:
|
|
102
|
+
info = downloader.extract_info(
|
|
103
|
+
f"ytsearch{request_limit}:{query}", download=False
|
|
104
|
+
)
|
|
105
|
+
cached = [
|
|
106
|
+
normalized
|
|
107
|
+
for entry in (info.get("entries") or [])
|
|
108
|
+
if (normalized := normalize_ytdlp_entry(entry)) is not None
|
|
109
|
+
]
|
|
110
|
+
_YTDLP_CACHE[cache_key] = cached
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
raise SearchError(
|
|
113
|
+
"A busca do YouTube e o fallback yt-dlp estão indisponíveis."
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
results = cached[offset : offset + count]
|
|
117
|
+
next_token = (
|
|
118
|
+
f"yt:{offset + count}"
|
|
119
|
+
if offset + count < len(cached)
|
|
120
|
+
else None
|
|
121
|
+
)
|
|
122
|
+
return results, next_token
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ytdlp_offset(page_token: Optional[str]) -> int:
|
|
126
|
+
if not page_token or not page_token.startswith("yt:"):
|
|
127
|
+
return 0
|
|
128
|
+
try:
|
|
129
|
+
return max(0, int(page_token[3:]))
|
|
130
|
+
except ValueError:
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def normalize_ytdlp_entry(entry: Any) -> Optional[dict[str, Any]]:
|
|
135
|
+
if not isinstance(entry, dict):
|
|
136
|
+
return None
|
|
137
|
+
video_id = str(entry.get("id", "")).strip()
|
|
138
|
+
title = str(entry.get("title") or "Sem título").strip()
|
|
139
|
+
if not video_id:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
channel = str(entry.get("channel") or entry.get("uploader") or "").strip()
|
|
143
|
+
searchable = f"{title} {channel}".casefold()
|
|
144
|
+
music_markers = ("music", "música", "song", "audio", "lyrics", "official", "topic", "vevo", "remaster", "live")
|
|
145
|
+
if any(marker in searchable for marker in _NON_MUSIC_MARKERS) and not any(
|
|
146
|
+
marker in searchable for marker in music_markers
|
|
147
|
+
):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
thumbnails = entry.get("thumbnails") or []
|
|
151
|
+
thumbnail = max(
|
|
152
|
+
thumbnails,
|
|
153
|
+
key=lambda item: item.get("width", 0) if isinstance(item, dict) else 0,
|
|
154
|
+
default={},
|
|
155
|
+
)
|
|
156
|
+
url = str(
|
|
157
|
+
entry.get("webpage_url")
|
|
158
|
+
or entry.get("url")
|
|
159
|
+
or f"https://www.youtube.com/watch?v={video_id}"
|
|
160
|
+
)
|
|
161
|
+
if url.startswith("/"):
|
|
162
|
+
url = "https://www.youtube.com" + url
|
|
163
|
+
duration = entry.get("duration", 0)
|
|
164
|
+
try:
|
|
165
|
+
duration = float(duration or 0)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
duration = 0.0
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"id": video_id,
|
|
171
|
+
"title": title,
|
|
172
|
+
"url": url,
|
|
173
|
+
"duration": duration,
|
|
174
|
+
"is_music": True,
|
|
175
|
+
"thumbnail": thumbnail.get("url", "") if isinstance(thumbnail, dict) else "",
|
|
176
|
+
"channelTitle": channel,
|
|
177
|
+
"artist": channel or None,
|
|
178
|
+
"source": "yt-dlp",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _search_oauth(
|
|
183
|
+
service: Any,
|
|
184
|
+
query: str,
|
|
185
|
+
count: int,
|
|
186
|
+
page_token: Optional[str],
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
kwargs: dict[str, Any] = {
|
|
189
|
+
"part": "snippet",
|
|
190
|
+
"q": query,
|
|
191
|
+
"type": "video",
|
|
192
|
+
"videoCategoryId": _MUSIC_CATEGORY_ID,
|
|
193
|
+
"maxResults": count,
|
|
194
|
+
"order": "relevance",
|
|
195
|
+
}
|
|
196
|
+
if page_token:
|
|
197
|
+
kwargs["pageToken"] = page_token
|
|
198
|
+
return service.search().list(**kwargs).execute()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _search_with_key(
|
|
202
|
+
query: str,
|
|
203
|
+
count: int,
|
|
204
|
+
page_token: Optional[str],
|
|
205
|
+
api_key: str,
|
|
206
|
+
) -> dict[str, Any]:
|
|
207
|
+
params: dict[str, Any] = {
|
|
208
|
+
"part": "snippet",
|
|
209
|
+
"q": query,
|
|
210
|
+
"type": "video",
|
|
211
|
+
"videoCategoryId": _MUSIC_CATEGORY_ID,
|
|
212
|
+
"maxResults": count,
|
|
213
|
+
"order": "relevance",
|
|
214
|
+
"key": api_key,
|
|
215
|
+
}
|
|
216
|
+
if page_token:
|
|
217
|
+
params["pageToken"] = page_token
|
|
218
|
+
|
|
219
|
+
http_response = requests.get(_SEARCH_URL, params=params, timeout=15)
|
|
220
|
+
if http_response.status_code == 429:
|
|
221
|
+
raise ValueError("A cota diária de buscas do YouTube foi atingida.")
|
|
222
|
+
if http_response.status_code == 403:
|
|
223
|
+
raise ValueError("A YOUTUBE_API_KEY não tem acesso ou cuota disponível.")
|
|
224
|
+
if http_response.status_code == 400:
|
|
225
|
+
raise ValueError("O YouTube recusou os parâmetros da busca.")
|
|
226
|
+
http_response.raise_for_status()
|
|
227
|
+
return http_response.json()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _build_results(
|
|
231
|
+
response: dict[str, Any],
|
|
232
|
+
service: Any = None,
|
|
233
|
+
api_key: Optional[str] = None,
|
|
234
|
+
) -> tuple[list[dict[str, Any]], Optional[str]]:
|
|
235
|
+
items = response.get("items", [])
|
|
236
|
+
video_ids = [
|
|
237
|
+
item.get("id", {}).get("videoId", "")
|
|
238
|
+
for item in items
|
|
239
|
+
if item.get("id", {}).get("videoId")
|
|
240
|
+
]
|
|
241
|
+
details = _video_details(video_ids, service=service, api_key=api_key)
|
|
242
|
+
|
|
243
|
+
results: list[dict[str, Any]] = []
|
|
244
|
+
for item in items:
|
|
245
|
+
video_id = item.get("id", {}).get("videoId", "")
|
|
246
|
+
if not video_id:
|
|
247
|
+
continue
|
|
248
|
+
snippet = item.get("snippet", {})
|
|
249
|
+
detail = details.get(video_id, {})
|
|
250
|
+
detail_snippet = detail.get("snippet", {})
|
|
251
|
+
thumbnails = snippet.get("thumbnails", {}) or {}
|
|
252
|
+
thumbnail = max(
|
|
253
|
+
thumbnails.values(),
|
|
254
|
+
key=lambda value: value.get("width", 0),
|
|
255
|
+
default={},
|
|
256
|
+
).get("url", "")
|
|
257
|
+
channel = str(
|
|
258
|
+
snippet.get("channelTitle")
|
|
259
|
+
or detail_snippet.get("channelTitle")
|
|
260
|
+
or ""
|
|
261
|
+
)
|
|
262
|
+
category_id = str(
|
|
263
|
+
detail_snippet.get("categoryId")
|
|
264
|
+
or snippet.get("categoryId")
|
|
265
|
+
or ""
|
|
266
|
+
)
|
|
267
|
+
if category_id != _MUSIC_CATEGORY_ID:
|
|
268
|
+
continue
|
|
269
|
+
results.append(
|
|
270
|
+
{
|
|
271
|
+
"id": video_id,
|
|
272
|
+
"title": snippet.get("title", "Sem título"),
|
|
273
|
+
"url": f"https://www.youtube.com/watch?v={video_id}",
|
|
274
|
+
"duration": _parse_duration(
|
|
275
|
+
detail.get("contentDetails", {}).get("duration", "")
|
|
276
|
+
),
|
|
277
|
+
"is_music": category_id == _MUSIC_CATEGORY_ID,
|
|
278
|
+
"thumbnail": thumbnail,
|
|
279
|
+
"channelTitle": channel,
|
|
280
|
+
"artist": channel or None,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
next_page = response.get("nextPageToken")
|
|
285
|
+
return results, str(next_page) if next_page else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _video_details(
|
|
289
|
+
video_ids: list[str],
|
|
290
|
+
service: Any = None,
|
|
291
|
+
api_key: Optional[str] = None,
|
|
292
|
+
) -> dict[str, dict[str, Any]]:
|
|
293
|
+
if not video_ids:
|
|
294
|
+
return {}
|
|
295
|
+
ids = ",".join(video_ids[:50])
|
|
296
|
+
try:
|
|
297
|
+
if service is not None:
|
|
298
|
+
response = (
|
|
299
|
+
service.videos()
|
|
300
|
+
.list(part="snippet,contentDetails", id=ids)
|
|
301
|
+
.execute()
|
|
302
|
+
)
|
|
303
|
+
elif api_key:
|
|
304
|
+
response = requests.get(
|
|
305
|
+
_VIDEOS_URL,
|
|
306
|
+
params={
|
|
307
|
+
"part": "snippet,contentDetails",
|
|
308
|
+
"id": ids,
|
|
309
|
+
"key": api_key,
|
|
310
|
+
},
|
|
311
|
+
timeout=15,
|
|
312
|
+
)
|
|
313
|
+
response.raise_for_status()
|
|
314
|
+
response = response.json()
|
|
315
|
+
else:
|
|
316
|
+
return {}
|
|
317
|
+
except (HttpError, requests.RequestException, ValueError):
|
|
318
|
+
return {}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
item["id"]: item
|
|
322
|
+
for item in response.get("items", [])
|
|
323
|
+
if item.get("id")
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _parse_duration(value: Any) -> float:
|
|
328
|
+
"""Converte a duração ISO 8601 da API para segundos."""
|
|
329
|
+
if not isinstance(value, str) or not value.startswith("P"):
|
|
330
|
+
return 0.0
|
|
331
|
+
try:
|
|
332
|
+
time_part = value[1:].split("T", 1)[1]
|
|
333
|
+
days = 0
|
|
334
|
+
if "D" in time_part:
|
|
335
|
+
day_part, time_part = time_part.split("D", 1)
|
|
336
|
+
days = int(day_part)
|
|
337
|
+
hours = 0
|
|
338
|
+
minutes = 0
|
|
339
|
+
seconds = 0.0
|
|
340
|
+
if "H" in time_part:
|
|
341
|
+
hour_part, time_part = time_part.split("H", 1)
|
|
342
|
+
hours = int(hour_part)
|
|
343
|
+
if "M" in time_part:
|
|
344
|
+
minute_part, time_part = time_part.split("M", 1)
|
|
345
|
+
minutes = int(minute_part)
|
|
346
|
+
seconds = float(time_part.rstrip("S"))
|
|
347
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds
|
|
348
|
+
except (IndexError, TypeError, ValueError):
|
|
349
|
+
return 0.0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _safe_http_error(error: HttpError) -> str:
|
|
353
|
+
status = getattr(getattr(error, "resp", None), "status", None)
|
|
354
|
+
if status == 429:
|
|
355
|
+
return "A cota diária de buscas do YouTube foi atingida. Aguarde o reset ou use outro projeto/API key."
|
|
356
|
+
if status == 403:
|
|
357
|
+
return "A conta ou o projeto não tem quota disponível para a API."
|
|
358
|
+
if status == 401:
|
|
359
|
+
return "A sessão expirou. Entre na conta novamente."
|
|
360
|
+
if status:
|
|
361
|
+
return f"A API do YouTube respondeu com HTTP {status}."
|
|
362
|
+
return "A API do YouTube recusou a solicitação."
|