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.
@@ -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."