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.
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": "1.2.0",
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
- "requirements.txt"
14
- ],
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
- "keywords": [
19
- "tui",
20
- "youtube",
21
- "music",
22
- "cli"
23
- ],
24
- "author": "Bruno Soares",
25
- "license": "MIT",
26
- "dependencies": {
27
- "dotenv": "^17.4.2"
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.50.0
2
- yt-dlp>=2024.3.10
3
- google-api-python-client>=2.100.0
4
- google-auth-oauthlib>=1.2.0
5
- av>=11.0.0
6
- sounddevice>=0.4.6
7
- numpy>=1.26.0
8
- python-dotenv>=1.0.0
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
@@ -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
@@ -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 {}
@@ -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