@danhachuel/thunderbolt 0.3.34 → 0.3.36
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/.streamlit/config.toml +1 -0
- package/MANUAL-INSTALACAO.md +29 -6
- package/README.md +22 -10
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/app/main.py +430 -84
- package/hermes_ui/api_key_tests.py +12 -0
- package/hermes_ui/languages.py +21 -0
- package/hermes_ui/notifications.py +22 -0
- package/hermes_ui/storage.py +30 -0
- package/integrations/music_uploads.py +381 -0
- package/integrations/telegram_gateway.py +158 -0
- package/package.json +1 -1
- package/requirements.txt +3 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import re
|
|
6
7
|
import uuid
|
|
7
8
|
from datetime import datetime, timezone
|
|
@@ -40,6 +41,8 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
|
|
|
40
41
|
{"code": "mcp_operation_completed", "category": "Integrações", "label": "Operação MCP concluída", "description": "Quando uma operação mutável de integração MCP terminar com sucesso."},
|
|
41
42
|
)
|
|
42
43
|
EVENTS_BY_CODE = {item["code"]: item for item in EVENT_CATALOG}
|
|
44
|
+
LOGGER = logging.getLogger(__name__)
|
|
45
|
+
|
|
43
46
|
SENSITIVE_MARKERS = (
|
|
44
47
|
"token",
|
|
45
48
|
"secret",
|
|
@@ -119,6 +122,24 @@ def _history() -> list[dict[str, Any]]:
|
|
|
119
122
|
return [item for item in saved if isinstance(item, dict)]
|
|
120
123
|
|
|
121
124
|
|
|
125
|
+
def _dispatch_telegram_notification(entry: dict[str, Any]) -> None:
|
|
126
|
+
"""Deliver a persisted event to Telegram without affecting local flows."""
|
|
127
|
+
try:
|
|
128
|
+
settings = storage.read_json("settings.json", {})
|
|
129
|
+
if not isinstance(settings, dict) or not bool(settings.get("telegram_enabled", False)):
|
|
130
|
+
return
|
|
131
|
+
from integrations.telegram_gateway import send_notification_to_telegram
|
|
132
|
+
|
|
133
|
+
result = send_notification_to_telegram(entry, settings)
|
|
134
|
+
if not result.ok:
|
|
135
|
+
error_type = result.data.get("error_type") if isinstance(result.data, dict) else ""
|
|
136
|
+
if error_type != "missing_configuration":
|
|
137
|
+
LOGGER.warning("Telegram notification delivery failed (%s).", error_type or "unknown_error")
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
# External notification delivery must never break production, uploads or UI.
|
|
140
|
+
LOGGER.warning("Telegram notification delivery failed (%s).", type(exc).__name__)
|
|
141
|
+
|
|
142
|
+
|
|
122
143
|
def record_notification(
|
|
123
144
|
event_type: str,
|
|
124
145
|
title: str,
|
|
@@ -151,6 +172,7 @@ def record_notification(
|
|
|
151
172
|
"dedupe_key": str(dedupe_key or ""),
|
|
152
173
|
}
|
|
153
174
|
storage.write_json(NOTIFICATIONS_FILE, [entry, *history][:MAX_NOTIFICATIONS])
|
|
175
|
+
_dispatch_telegram_notification(entry)
|
|
154
176
|
return entry
|
|
155
177
|
|
|
156
178
|
|
package/hermes_ui/storage.py
CHANGED
|
@@ -96,6 +96,11 @@ DEFAULTS: dict[str, Any] = {
|
|
|
96
96
|
"youtube_client_secret": "",
|
|
97
97
|
"youtube_batch_accounts": [],
|
|
98
98
|
"youtube_batch_selected_account_id": "",
|
|
99
|
+
"telegram_enabled": False,
|
|
100
|
+
"telegram_bot_token": "",
|
|
101
|
+
"telegram_chat_id": "",
|
|
102
|
+
"telegram_proxy_url": "",
|
|
103
|
+
"telegram_timeout_seconds": 15,
|
|
99
104
|
"notification_preferences": {
|
|
100
105
|
"video_completed": True,
|
|
101
106
|
"music_completed": True,
|
|
@@ -263,6 +268,31 @@ DEFAULTS: dict[str, Any] = {
|
|
|
263
268
|
"suno_api_key": "",
|
|
264
269
|
"suno_api_base_url": "",
|
|
265
270
|
"suno_api_endpoint": "/api/generate",
|
|
271
|
+
"jewelmusic_enabled": False,
|
|
272
|
+
"jewelmusic_api_key": "",
|
|
273
|
+
"jewelmusic_base_url": "https://api.jewelmusic.com",
|
|
274
|
+
"jewelmusic_proxy_url": "",
|
|
275
|
+
"jewelmusic_timeout_seconds": 120,
|
|
276
|
+
"pushtunes_enabled": False,
|
|
277
|
+
"pushtunes_executable": "pushtunes",
|
|
278
|
+
"pushtunes_source": "csv",
|
|
279
|
+
"pushtunes_target": "ytm",
|
|
280
|
+
"pushtunes_operation": "tracks",
|
|
281
|
+
"pushtunes_profile": "",
|
|
282
|
+
"pushtunes_csv_file": "",
|
|
283
|
+
"pushtunes_ytm_auth_file": "",
|
|
284
|
+
"pushtunes_tidal_session_file": "",
|
|
285
|
+
"pushtunes_playlist_name": "",
|
|
286
|
+
"pushtunes_similarity": 0.8,
|
|
287
|
+
"pushtunes_working_directory": "",
|
|
288
|
+
"pushtunes_spotify_client_id": "",
|
|
289
|
+
"pushtunes_spotify_client_secret": "",
|
|
290
|
+
"pushtunes_spotify_redirect_uri": "",
|
|
291
|
+
"pushtunes_timeout_seconds": 1800,
|
|
292
|
+
"ytmusicapi_enabled": False,
|
|
293
|
+
"ytmusicapi_auth_file": "",
|
|
294
|
+
"ytmusicapi_proxy_url": "",
|
|
295
|
+
"ytmusicapi_timeout_seconds": 240,
|
|
266
296
|
"voice_preview_provider": "edge",
|
|
267
297
|
"voice_preview_rate": "+0%",
|
|
268
298
|
"direct_cookie_sid": "",
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Music upload integrations used by the Thunderbolt Upload Música page.
|
|
2
|
+
|
|
3
|
+
The adapters deliberately keep provider-specific behaviour behind the existing
|
|
4
|
+
IntegrationResult contract. They never log or return secrets and they do not
|
|
5
|
+
perform a remote write from a credential-validation call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import mimetypes
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from .platforms import IntegrationResult
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MUSIC_UPLOAD_EXTENSIONS = {".mp3", ".m4a", ".wma", ".flac", ".ogg", ".wav", ".aac"}
|
|
26
|
+
YT_MUSIC_UPLOAD_EXTENSIONS = {".mp3", ".m4a", ".wma", ".flac", ".ogg"}
|
|
27
|
+
DEFAULT_JEWELMUSIC_BASE_URL = "https://api.jewelmusic.com"
|
|
28
|
+
DEFAULT_JEWELMUSIC_TIMEOUT = 120
|
|
29
|
+
DEFAULT_YTMUSICAPI_TIMEOUT = 240
|
|
30
|
+
DEFAULT_PUSHTUNES_TIMEOUT = 1800
|
|
31
|
+
PUSHTUNES_SOURCES = ("subsonic", "jellyfin", "csv", "spotify", "ytm")
|
|
32
|
+
PUSHTUNES_TARGETS = ("spotify", "ytm", "tidal", "csv")
|
|
33
|
+
PUSHTUNES_OPERATIONS = ("tracks", "albums", "playlist")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bounded_timeout(value: Any, default: int, maximum: int) -> int:
|
|
37
|
+
try:
|
|
38
|
+
return max(5, min(maximum, int(value)))
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _proxy_kwargs(proxy_url: str) -> dict[str, dict[str, str]]:
|
|
44
|
+
proxy = str(proxy_url or "").strip()
|
|
45
|
+
return {"proxies": {"http": proxy, "https": proxy}} if proxy else {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _audio_path(value: str | Path, allowed_extensions: set[str] | None = None) -> tuple[Path | None, str | None]:
|
|
49
|
+
path = Path(value).expanduser() if str(value or "").strip() else Path("")
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return None, f"Ficheiro de música não encontrado: {path}"
|
|
52
|
+
if path.stat().st_size <= 0:
|
|
53
|
+
return None, f"O ficheiro de música está vazio: {path.name}"
|
|
54
|
+
extensions = allowed_extensions or MUSIC_UPLOAD_EXTENSIONS
|
|
55
|
+
if path.suffix.lower() not in extensions:
|
|
56
|
+
allowed = ", ".join(sorted(extensions))
|
|
57
|
+
return None, f"Formato não suportado para este destino: {path.suffix or '(sem extensão)'}. Use {allowed}."
|
|
58
|
+
return path, None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _response_payload(response: requests.Response) -> Any:
|
|
62
|
+
try:
|
|
63
|
+
return response.json()
|
|
64
|
+
except ValueError:
|
|
65
|
+
return {"text": response.text[:2000]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _redact_output(text: str, secrets: list[str]) -> str:
|
|
69
|
+
result = str(text or "")
|
|
70
|
+
for secret in secrets:
|
|
71
|
+
if secret:
|
|
72
|
+
result = result.replace(secret, "[redacted]")
|
|
73
|
+
return result[-8000:]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _error(message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
|
|
77
|
+
return IntegrationResult(False, message, data or {})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class JewelMusicAdapter:
|
|
81
|
+
"""Client for JewelMusic's documented ``POST /v1/tracks/upload`` API.
|
|
82
|
+
|
|
83
|
+
The public GitHub repository currently documents the Python SDK but does
|
|
84
|
+
not publish ``jewelmusic-sdk`` on PyPI. Thunderbolt therefore mirrors the
|
|
85
|
+
SDK's documented HTTP contract with requests instead of adding an
|
|
86
|
+
unavailable dependency.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
90
|
+
self.settings = settings or {}
|
|
91
|
+
self.enabled = bool(self.settings.get("jewelmusic_enabled", False))
|
|
92
|
+
self.api_key = str(self.settings.get("jewelmusic_api_key") or "").strip()
|
|
93
|
+
self.base_url = str(self.settings.get("jewelmusic_base_url") or DEFAULT_JEWELMUSIC_BASE_URL).strip().rstrip("/")
|
|
94
|
+
self.timeout = _bounded_timeout(self.settings.get("jewelmusic_timeout_seconds"), DEFAULT_JEWELMUSIC_TIMEOUT, 900)
|
|
95
|
+
self.proxy_url = str(self.settings.get("jewelmusic_proxy_url") or "").strip()
|
|
96
|
+
|
|
97
|
+
def _url(self, path: str) -> str:
|
|
98
|
+
return f"{self.base_url}/v1/{path.lstrip('/')}"
|
|
99
|
+
|
|
100
|
+
def _headers(self) -> dict[str, str]:
|
|
101
|
+
return {
|
|
102
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
103
|
+
"Accept": "application/json",
|
|
104
|
+
"User-Agent": "Thunderbolt-JewelMusic/0.3",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def status(self) -> IntegrationResult:
|
|
108
|
+
if not self.enabled:
|
|
109
|
+
return _error("JewelMusic está desactivado nesta subaba.", {"status": "disabled"})
|
|
110
|
+
if not self.api_key:
|
|
111
|
+
return _error("JewelMusic não está configurado: introduza a API Key.", {"status": "missing_api_key"})
|
|
112
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
113
|
+
return _error("A Base URL JewelMusic deve começar por http:// ou https://.", {"status": "invalid_base_url"})
|
|
114
|
+
return IntegrationResult(True, "JewelMusic configurado.", {"base_url": self.base_url, "api_key_configured": True})
|
|
115
|
+
|
|
116
|
+
def test_connection(self) -> IntegrationResult:
|
|
117
|
+
status = self.status()
|
|
118
|
+
if not status.ok:
|
|
119
|
+
return status
|
|
120
|
+
try:
|
|
121
|
+
response = requests.get(self._url("ping"), headers=self._headers(), timeout=self.timeout, **_proxy_kwargs(self.proxy_url))
|
|
122
|
+
except requests.RequestException as exc:
|
|
123
|
+
return _error(f"Não foi possível contactar o JewelMusic: {exc}", {"status": "network_error"})
|
|
124
|
+
payload = _response_payload(response)
|
|
125
|
+
if response.status_code >= 400:
|
|
126
|
+
return _error(f"JewelMusic rejeitou a validação (HTTP {response.status_code}).", {"status_code": response.status_code, "payload": payload})
|
|
127
|
+
return IntegrationResult(True, "Ligação JewelMusic validada sem criar uma track.", {"status_code": response.status_code, "payload": payload})
|
|
128
|
+
|
|
129
|
+
def upload_track(self, audio_path: str | Path, *, title: str, artist: str, album: str = "", year: str = "", genre: str = "") -> IntegrationResult:
|
|
130
|
+
status = self.status()
|
|
131
|
+
if not status.ok:
|
|
132
|
+
return status
|
|
133
|
+
path, error = _audio_path(audio_path)
|
|
134
|
+
if error or path is None:
|
|
135
|
+
return _error(error or "Ficheiro de música inválido.")
|
|
136
|
+
clean_title = str(title or "").strip() or path.stem
|
|
137
|
+
clean_artist = str(artist or "").strip()
|
|
138
|
+
if not clean_artist:
|
|
139
|
+
return _error("Indique o artista antes de enviar para o JewelMusic.")
|
|
140
|
+
metadata = {
|
|
141
|
+
"title": clean_title,
|
|
142
|
+
"artist": clean_artist,
|
|
143
|
+
"album": str(album or "").strip(),
|
|
144
|
+
"year": str(year or "").strip(),
|
|
145
|
+
"genre": str(genre or "").strip(),
|
|
146
|
+
}
|
|
147
|
+
metadata = {key: value for key, value in metadata.items() if value}
|
|
148
|
+
content_type = mimetypes.guess_type(path.name)[0] or "audio/mpeg"
|
|
149
|
+
try:
|
|
150
|
+
with path.open("rb") as handle:
|
|
151
|
+
response = requests.post(
|
|
152
|
+
self._url("tracks/upload"),
|
|
153
|
+
headers=self._headers(),
|
|
154
|
+
data=metadata,
|
|
155
|
+
files={"file": (path.name, handle, content_type)},
|
|
156
|
+
timeout=self.timeout,
|
|
157
|
+
**_proxy_kwargs(self.proxy_url),
|
|
158
|
+
)
|
|
159
|
+
except requests.RequestException as exc:
|
|
160
|
+
return _error(f"Não foi possível enviar a música para o JewelMusic: {exc}", {"filename": path.name})
|
|
161
|
+
payload = _response_payload(response)
|
|
162
|
+
if response.status_code >= 400:
|
|
163
|
+
return _error(f"JewelMusic rejeitou a música (HTTP {response.status_code}).", {"filename": path.name, "status_code": response.status_code, "payload": payload})
|
|
164
|
+
return IntegrationResult(True, f"Música enviada para o JewelMusic: {clean_title}.", {"filename": path.name, "title": clean_title, "artist": clean_artist, "payload": payload, "status_code": response.status_code})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class YTMusicApiAdapter:
|
|
168
|
+
"""Adapter around ytmusicapi's browser-authenticated upload_song method."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
171
|
+
self.settings = settings or {}
|
|
172
|
+
self.enabled = bool(self.settings.get("ytmusicapi_enabled", False))
|
|
173
|
+
self.auth_file = str(self.settings.get("ytmusicapi_auth_file") or "").strip()
|
|
174
|
+
self.proxy_url = str(self.settings.get("ytmusicapi_proxy_url") or "").strip()
|
|
175
|
+
self.timeout = _bounded_timeout(self.settings.get("ytmusicapi_timeout_seconds"), DEFAULT_YTMUSICAPI_TIMEOUT, 900)
|
|
176
|
+
|
|
177
|
+
def _auth_path(self) -> Path:
|
|
178
|
+
return Path(self.auth_file).expanduser()
|
|
179
|
+
|
|
180
|
+
def status(self) -> IntegrationResult:
|
|
181
|
+
if not self.enabled:
|
|
182
|
+
return _error("ytmusicapi está desactivado nesta subaba.", {"status": "disabled"})
|
|
183
|
+
if not self.auth_file:
|
|
184
|
+
return _error("ytmusicapi não está configurado: indique o caminho do browser.json.", {"status": "missing_auth_file"})
|
|
185
|
+
path = self._auth_path()
|
|
186
|
+
if not path.is_file():
|
|
187
|
+
return _error(f"Ficheiro de autenticação ytmusicapi não encontrado: {path}", {"status": "missing_auth_file", "path": str(path)})
|
|
188
|
+
try:
|
|
189
|
+
from ytmusicapi import YTMusic # type: ignore
|
|
190
|
+
except ImportError:
|
|
191
|
+
return _error("A dependência ytmusicapi não está instalada. Execute novamente a instalação do Thunderbolt.", {"status": "dependency_missing"})
|
|
192
|
+
try:
|
|
193
|
+
YTMusic(str(path), proxies=_proxy_kwargs(self.proxy_url).get("proxies"))
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
return _error(f"O browser.json não foi aceite pelo ytmusicapi: {exc}", {"status": "invalid_auth_file", "path": str(path)})
|
|
196
|
+
return IntegrationResult(True, "ytmusicapi pronto com autenticação de browser.", {"auth_file": str(path), "auth_type": "browser"})
|
|
197
|
+
|
|
198
|
+
def _client(self):
|
|
199
|
+
from ytmusicapi import YTMusic # type: ignore
|
|
200
|
+
|
|
201
|
+
return YTMusic(str(self._auth_path()), proxies=_proxy_kwargs(self.proxy_url).get("proxies"))
|
|
202
|
+
|
|
203
|
+
def test_connection(self) -> IntegrationResult:
|
|
204
|
+
status = self.status()
|
|
205
|
+
if not status.ok:
|
|
206
|
+
return status
|
|
207
|
+
try:
|
|
208
|
+
songs = self._client().get_library_upload_songs(limit=1)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
return _error(f"Não foi possível consultar a biblioteca de uploads do YouTube Music: {exc}", {"status": "network_or_auth_error"})
|
|
211
|
+
count = len(songs) if isinstance(songs, list) else 0
|
|
212
|
+
return IntegrationResult(True, "Autenticação ytmusicapi validada com consulta read-only.", {"sample_count": count})
|
|
213
|
+
|
|
214
|
+
def upload_song(self, audio_path: str | Path) -> IntegrationResult:
|
|
215
|
+
status = self.status()
|
|
216
|
+
if not status.ok:
|
|
217
|
+
return status
|
|
218
|
+
path, error = _audio_path(audio_path, YT_MUSIC_UPLOAD_EXTENSIONS)
|
|
219
|
+
if error or path is None:
|
|
220
|
+
return _error(error or "Ficheiro de música inválido.")
|
|
221
|
+
if path.stat().st_size >= 300 * 1024 * 1024:
|
|
222
|
+
return _error("O YouTube Music não aceita uploads com 300 MB ou mais.", {"filename": path.name})
|
|
223
|
+
try:
|
|
224
|
+
result = self._client().upload_song(str(path))
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
return _error(f"Não foi possível enviar a música para o YouTube Music: {exc}", {"filename": path.name})
|
|
227
|
+
result_name = str(getattr(result, "name", result))
|
|
228
|
+
if "SUCCEEDED" not in result_name.upper():
|
|
229
|
+
data: dict[str, Any] = {"filename": path.name, "result": result_name}
|
|
230
|
+
if hasattr(result, "status_code"):
|
|
231
|
+
data["status_code"] = result.status_code
|
|
232
|
+
return _error(f"O YouTube Music não confirmou o upload de {path.name}.", data)
|
|
233
|
+
return IntegrationResult(True, f"Música enviada para o YouTube Music: {path.stem}.", {"filename": path.name, "result": result_name})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class PushtunesAdapter:
|
|
237
|
+
"""Safe wrapper around the Pushtunes CLI library synchronizer.
|
|
238
|
+
|
|
239
|
+
Pushtunes moves library metadata between local sources and Spotify, YTM or
|
|
240
|
+
Tidal. It does not upload arbitrary local MP3 bytes, so the UI exposes the
|
|
241
|
+
supported source/target sync operations instead of mislabelling them as a
|
|
242
|
+
single-file upload.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
246
|
+
self.settings = settings or {}
|
|
247
|
+
self.enabled = bool(self.settings.get("pushtunes_enabled", False))
|
|
248
|
+
self.executable = str(self.settings.get("pushtunes_executable") or "pushtunes").strip()
|
|
249
|
+
self.source = str(self.settings.get("pushtunes_source") or "csv").strip().lower()
|
|
250
|
+
self.target = str(self.settings.get("pushtunes_target") or "ytm").strip().lower()
|
|
251
|
+
self.operation = str(self.settings.get("pushtunes_operation") or "tracks").strip().lower()
|
|
252
|
+
self.profile = str(self.settings.get("pushtunes_profile") or "").strip()
|
|
253
|
+
self.csv_file = str(self.settings.get("pushtunes_csv_file") or "").strip()
|
|
254
|
+
self.ytm_auth_file = str(self.settings.get("pushtunes_ytm_auth_file") or "").strip()
|
|
255
|
+
self.tidal_session_file = str(self.settings.get("pushtunes_tidal_session_file") or "").strip()
|
|
256
|
+
self.playlist_name = str(self.settings.get("pushtunes_playlist_name") or "").strip()
|
|
257
|
+
self.similarity = self.settings.get("pushtunes_similarity", 0.8)
|
|
258
|
+
self.working_directory = str(self.settings.get("pushtunes_working_directory") or "").strip()
|
|
259
|
+
self.spotify_client_id = str(self.settings.get("pushtunes_spotify_client_id") or "").strip()
|
|
260
|
+
self.spotify_client_secret = str(self.settings.get("pushtunes_spotify_client_secret") or "").strip()
|
|
261
|
+
self.spotify_redirect_uri = str(self.settings.get("pushtunes_spotify_redirect_uri") or "").strip()
|
|
262
|
+
self.timeout = _bounded_timeout(self.settings.get("pushtunes_timeout_seconds"), DEFAULT_PUSHTUNES_TIMEOUT, 3600)
|
|
263
|
+
|
|
264
|
+
def _command_prefix(self) -> list[str] | None:
|
|
265
|
+
if Path(self.executable).expanduser().is_file() or shutil.which(self.executable):
|
|
266
|
+
return [str(Path(self.executable).expanduser())] if Path(self.executable).expanduser().is_file() else [self.executable]
|
|
267
|
+
if self.executable == "pushtunes":
|
|
268
|
+
try:
|
|
269
|
+
import importlib.util
|
|
270
|
+
|
|
271
|
+
if importlib.util.find_spec("pushtunes") is not None:
|
|
272
|
+
return [sys.executable, "-m", "pushtunes.cli.main"]
|
|
273
|
+
except (ImportError, ValueError):
|
|
274
|
+
pass
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def status(self) -> IntegrationResult:
|
|
278
|
+
if not self.enabled:
|
|
279
|
+
return _error("Pushtunes está desactivado nesta subaba.", {"status": "disabled"})
|
|
280
|
+
prefix = self._command_prefix()
|
|
281
|
+
if prefix is None:
|
|
282
|
+
return _error("Pushtunes não está instalado ou o executável não foi encontrado. Execute novamente a instalação do Thunderbolt.", {"status": "executable_missing", "executable": self.executable})
|
|
283
|
+
if self.source not in PUSHTUNES_SOURCES:
|
|
284
|
+
return _error(f"Fonte Pushtunes inválida: {self.source}.", {"status": "invalid_source", "allowed": list(PUSHTUNES_SOURCES)})
|
|
285
|
+
if self.target not in PUSHTUNES_TARGETS:
|
|
286
|
+
return _error(f"Destino Pushtunes inválido: {self.target}.", {"status": "invalid_target", "allowed": list(PUSHTUNES_TARGETS)})
|
|
287
|
+
if self.operation not in PUSHTUNES_OPERATIONS:
|
|
288
|
+
return _error(f"Operação Pushtunes inválida: {self.operation}.", {"status": "invalid_operation", "allowed": list(PUSHTUNES_OPERATIONS)})
|
|
289
|
+
if self.profile and not Path(self.profile).expanduser().is_file():
|
|
290
|
+
return _error(f"Perfil Pushtunes não encontrado: {self.profile}", {"status": "missing_profile"})
|
|
291
|
+
if self.source == "csv" and not self.csv_file:
|
|
292
|
+
return _error("Indique um CSV de origem quando a fonte Pushtunes for csv.", {"status": "missing_csv"})
|
|
293
|
+
if self.csv_file and not Path(self.csv_file).expanduser().is_file() and self.source == "csv":
|
|
294
|
+
return _error(f"CSV de origem Pushtunes não encontrado: {self.csv_file}", {"status": "missing_csv"})
|
|
295
|
+
if self.ytm_auth_file and not Path(self.ytm_auth_file).expanduser().is_file():
|
|
296
|
+
return _error(f"Ficheiro browser.json do YouTube Music não encontrado: {self.ytm_auth_file}", {"status": "missing_ytm_auth"})
|
|
297
|
+
if self.target == "tidal" and not self.tidal_session_file:
|
|
298
|
+
return _error("Indique o ficheiro tidal-session.json para usar o destino Tidal.", {"status": "missing_tidal_session"})
|
|
299
|
+
if self.tidal_session_file and not Path(self.tidal_session_file).expanduser().is_file():
|
|
300
|
+
return _error(f"Ficheiro de sessão Tidal não encontrado: {self.tidal_session_file}", {"status": "missing_tidal_session"})
|
|
301
|
+
if self.target == "csv" and not self.csv_file:
|
|
302
|
+
return _error("Indique um CSV de destino quando o alvo Pushtunes for csv.", {"status": "missing_csv"})
|
|
303
|
+
if self.operation == "playlist" and not self.playlist_name and not self.profile:
|
|
304
|
+
return _error("Indique o nome da playlist ou use um perfil Pushtunes.", {"status": "missing_playlist_name"})
|
|
305
|
+
if self.working_directory and not Path(self.working_directory).expanduser().is_dir():
|
|
306
|
+
return _error(f"Directório de trabalho Pushtunes não encontrado: {self.working_directory}", {"status": "missing_working_directory"})
|
|
307
|
+
return IntegrationResult(True, "Pushtunes pronto para sincronização de biblioteca.", {"command": prefix, "source": self.source, "target": self.target, "operation": self.operation})
|
|
308
|
+
|
|
309
|
+
def _args(self) -> list[str]:
|
|
310
|
+
args = ["push", self.operation, "--from", self.source, "--to", self.target, "--no-color"]
|
|
311
|
+
try:
|
|
312
|
+
args.extend(["--similarity", str(max(0.0, min(1.0, float(self.similarity))))])
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
args.extend(["--similarity", "0.8"])
|
|
315
|
+
if self.profile:
|
|
316
|
+
args.extend(["--profile", str(Path(self.profile).expanduser().resolve())])
|
|
317
|
+
if self.csv_file:
|
|
318
|
+
args.extend(["--csv-file", str(Path(self.csv_file).expanduser().resolve())])
|
|
319
|
+
if self.ytm_auth_file and self.operation in {"albums", "playlist"}:
|
|
320
|
+
args.extend(["--ytm-auth", str(Path(self.ytm_auth_file).expanduser().resolve())])
|
|
321
|
+
if self.operation == "playlist" and self.playlist_name:
|
|
322
|
+
args.extend(["--playlist-name", self.playlist_name])
|
|
323
|
+
return args
|
|
324
|
+
|
|
325
|
+
def sync(self) -> IntegrationResult:
|
|
326
|
+
status = self.status()
|
|
327
|
+
if not status.ok:
|
|
328
|
+
return status
|
|
329
|
+
prefix = self._command_prefix() or []
|
|
330
|
+
env = os.environ.copy()
|
|
331
|
+
if self.spotify_client_id:
|
|
332
|
+
env["SPOTIFY_CLIENT_ID"] = self.spotify_client_id
|
|
333
|
+
if self.spotify_client_secret:
|
|
334
|
+
env["SPOTIFY_CLIENT_SECRET"] = self.spotify_client_secret
|
|
335
|
+
if self.spotify_redirect_uri:
|
|
336
|
+
env["SPOTIFY_REDIRECT_URI"] = self.spotify_redirect_uri
|
|
337
|
+
command = [*prefix, *self._args()]
|
|
338
|
+
secrets = [self.spotify_client_id, self.spotify_client_secret]
|
|
339
|
+
temporary_cwd: tempfile.TemporaryDirectory[str] | None = None
|
|
340
|
+
execution_cwd = str(Path(self.working_directory).expanduser().resolve()) if self.working_directory else None
|
|
341
|
+
if (self.ytm_auth_file and self.operation == "tracks") or self.tidal_session_file:
|
|
342
|
+
temporary_cwd = tempfile.TemporaryDirectory(prefix="thunderbolt-pushtunes-")
|
|
343
|
+
if self.ytm_auth_file and self.operation == "tracks":
|
|
344
|
+
shutil.copy2(Path(self.ytm_auth_file).expanduser(), Path(temporary_cwd.name) / "browser.json")
|
|
345
|
+
if self.tidal_session_file:
|
|
346
|
+
shutil.copy2(Path(self.tidal_session_file).expanduser(), Path(temporary_cwd.name) / "tidal-session.json")
|
|
347
|
+
execution_cwd = temporary_cwd.name
|
|
348
|
+
try:
|
|
349
|
+
completed = subprocess.run(
|
|
350
|
+
command,
|
|
351
|
+
cwd=execution_cwd,
|
|
352
|
+
env=env,
|
|
353
|
+
capture_output=True,
|
|
354
|
+
text=True,
|
|
355
|
+
timeout=self.timeout,
|
|
356
|
+
check=False,
|
|
357
|
+
)
|
|
358
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
359
|
+
return _error(f"Não foi possível iniciar o Pushtunes: {exc}", {"status": "process_error"})
|
|
360
|
+
finally:
|
|
361
|
+
if temporary_cwd is not None:
|
|
362
|
+
temporary_cwd.cleanup()
|
|
363
|
+
stdout = _redact_output(completed.stdout, secrets)
|
|
364
|
+
stderr = _redact_output(completed.stderr, secrets)
|
|
365
|
+
data = {"command": command, "returncode": completed.returncode, "stdout": stdout, "stderr": stderr, "source": self.source, "target": self.target, "operation": self.operation}
|
|
366
|
+
if completed.returncode != 0:
|
|
367
|
+
return _error("O Pushtunes terminou com erro. Consulte a saída técnica abaixo e confirme as credenciais do serviço de origem/destino.", data)
|
|
368
|
+
return IntegrationResult(True, f"Pushtunes concluiu a sincronização {self.source} → {self.target}.", data)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
__all__ = [
|
|
372
|
+
"DEFAULT_JEWELMUSIC_BASE_URL",
|
|
373
|
+
"JewelMusicAdapter",
|
|
374
|
+
"MUSIC_UPLOAD_EXTENSIONS",
|
|
375
|
+
"PUSHTUNES_OPERATIONS",
|
|
376
|
+
"PUSHTUNES_SOURCES",
|
|
377
|
+
"PUSHTUNES_TARGETS",
|
|
378
|
+
"PushtunesAdapter",
|
|
379
|
+
"YTMusicApiAdapter",
|
|
380
|
+
"YT_MUSIC_UPLOAD_EXTENSIONS",
|
|
381
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Outbound Telegram Gateway integration for persisted Thunderbolt notifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .platforms import IntegrationResult
|
|
11
|
+
|
|
12
|
+
DEFAULT_TELEGRAM_API_BASE_URL = "https://api.telegram.org"
|
|
13
|
+
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
|
|
14
|
+
DEFAULT_TELEGRAM_TIMEOUT = 15
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TelegramGatewayAdapter:
|
|
18
|
+
"""Send Thunderbolt notifications through the Telegram Bot API.
|
|
19
|
+
|
|
20
|
+
The adapter deliberately implements outbound delivery only. It does not
|
|
21
|
+
poll Telegram or receive messages, which keeps notification delivery
|
|
22
|
+
isolated from the production and upload flows.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, settings: dict[str, Any] | None = None, *, request_client: Any = requests) -> None:
|
|
26
|
+
self.settings = settings or {}
|
|
27
|
+
self.enabled = bool(self.settings.get("telegram_enabled", False))
|
|
28
|
+
self.bot_token = str(self.settings.get("telegram_bot_token") or os.getenv("TELEGRAM_BOT_TOKEN", "")).strip()
|
|
29
|
+
self.chat_id = str(self.settings.get("telegram_chat_id") or os.getenv("TELEGRAM_CHAT_ID", "")).strip()
|
|
30
|
+
self.proxy_url = str(self.settings.get("telegram_proxy_url") or os.getenv("TELEGRAM_PROXY_URL", "")).strip()
|
|
31
|
+
self.timeout = max(1, int(self.settings.get("telegram_timeout_seconds", DEFAULT_TELEGRAM_TIMEOUT) or DEFAULT_TELEGRAM_TIMEOUT))
|
|
32
|
+
self._request_client = request_client
|
|
33
|
+
|
|
34
|
+
def _error(self, message: str, *, status_code: int | None = None, error_type: str = "") -> IntegrationResult:
|
|
35
|
+
data: dict[str, Any] = {}
|
|
36
|
+
if status_code is not None:
|
|
37
|
+
data["status_code"] = status_code
|
|
38
|
+
if error_type:
|
|
39
|
+
data["error_type"] = error_type
|
|
40
|
+
return IntegrationResult(False, message, data)
|
|
41
|
+
|
|
42
|
+
def status(self) -> IntegrationResult:
|
|
43
|
+
if not self.enabled:
|
|
44
|
+
return self._error("Telegram está desactivado nas notificações.", error_type="disabled")
|
|
45
|
+
if not self.bot_token or not self.chat_id:
|
|
46
|
+
return self._error("Telegram não está configurado: indique o Bot Token e o Chat ID.", error_type="missing_configuration")
|
|
47
|
+
return IntegrationResult(True, "Telegram configurado para notificações.", {"chat_id_configured": True})
|
|
48
|
+
|
|
49
|
+
def _url(self, method: str) -> str:
|
|
50
|
+
return f"{DEFAULT_TELEGRAM_API_BASE_URL}/bot{self.bot_token}/{method}"
|
|
51
|
+
|
|
52
|
+
def _request_kwargs(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
kwargs: dict[str, Any] = {"json": payload, "timeout": self.timeout}
|
|
54
|
+
if self.proxy_url:
|
|
55
|
+
kwargs["proxies"] = {"http": self.proxy_url, "https": self.proxy_url}
|
|
56
|
+
return kwargs
|
|
57
|
+
|
|
58
|
+
def _post(self, method: str, payload: dict[str, Any]) -> IntegrationResult:
|
|
59
|
+
try:
|
|
60
|
+
response = self._request_client.post(self._url(method), **self._request_kwargs(payload))
|
|
61
|
+
except requests.RequestException as exc:
|
|
62
|
+
return self._error("Não foi possível contactar o Telegram.", error_type=type(exc).__name__)
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
return self._error("A chamada ao Telegram falhou.", error_type=type(exc).__name__)
|
|
65
|
+
|
|
66
|
+
status_code = int(getattr(response, "status_code", 0) or 0)
|
|
67
|
+
if status_code == 429:
|
|
68
|
+
return self._error("O Telegram limitou a chamada de notificação; tente novamente mais tarde.", status_code=status_code, error_type="rate_limited")
|
|
69
|
+
if status_code in {401, 403}:
|
|
70
|
+
return self._error("O Telegram rejeitou o Bot Token ou não permite o envio para este Chat ID.", status_code=status_code, error_type="unauthorized")
|
|
71
|
+
if status_code < 200 or status_code >= 300:
|
|
72
|
+
return self._error("O Telegram rejeitou a notificação.", status_code=status_code or None, error_type="http_error")
|
|
73
|
+
try:
|
|
74
|
+
payload_response = response.json()
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return self._error("O Telegram devolveu uma resposta inválida.", status_code=status_code, error_type="invalid_json")
|
|
77
|
+
if not isinstance(payload_response, dict) or payload_response.get("ok") is not True:
|
|
78
|
+
return self._error("O Telegram não aceitou a notificação.", status_code=status_code, error_type="api_error")
|
|
79
|
+
return IntegrationResult(True, "Notificação enviada pelo Telegram.", {"status_code": status_code, "result": payload_response.get("result")})
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def split_message(text: str, *, max_length: int = TELEGRAM_MAX_MESSAGE_LENGTH) -> list[str]:
|
|
83
|
+
"""Split a message at line boundaries while respecting Telegram's limit."""
|
|
84
|
+
normalized = str(text or "").strip()
|
|
85
|
+
if not normalized:
|
|
86
|
+
return []
|
|
87
|
+
chunks: list[str] = []
|
|
88
|
+
remaining = normalized
|
|
89
|
+
while len(remaining) > max_length:
|
|
90
|
+
cut = remaining.rfind("\n", 0, max_length + 1)
|
|
91
|
+
if cut < max_length // 2:
|
|
92
|
+
cut = max_length
|
|
93
|
+
chunks.append(remaining[:cut].rstrip())
|
|
94
|
+
remaining = remaining[cut:].lstrip()
|
|
95
|
+
if remaining:
|
|
96
|
+
chunks.append(remaining)
|
|
97
|
+
return chunks
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def format_notification(notification: dict[str, Any]) -> str:
|
|
101
|
+
"""Format an already-redacted local notification for Telegram."""
|
|
102
|
+
title = str(notification.get("title") or notification.get("label") or "Notificação").strip()
|
|
103
|
+
message = str(notification.get("message") or "").strip()
|
|
104
|
+
category = str(notification.get("category") or "Sistema").strip()
|
|
105
|
+
created_at = str(notification.get("created_at") or "").strip()
|
|
106
|
+
lines = [title]
|
|
107
|
+
if message:
|
|
108
|
+
lines.append(message)
|
|
109
|
+
details: list[str] = []
|
|
110
|
+
if category:
|
|
111
|
+
details.append(f"Categoria: {category}")
|
|
112
|
+
if created_at:
|
|
113
|
+
details.append(f"Data: {created_at}")
|
|
114
|
+
metadata = notification.get("metadata")
|
|
115
|
+
if isinstance(metadata, dict):
|
|
116
|
+
for key, value in metadata.items():
|
|
117
|
+
if value in (None, "", [], {}):
|
|
118
|
+
continue
|
|
119
|
+
details.append(f"{key}: {value}")
|
|
120
|
+
if details:
|
|
121
|
+
lines.extend(["", *details])
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
def send_message(self, text: str) -> IntegrationResult:
|
|
125
|
+
"""Send plain text to the configured chat, splitting oversized messages."""
|
|
126
|
+
status = self.status()
|
|
127
|
+
if not status.ok:
|
|
128
|
+
return status
|
|
129
|
+
chunks = self.split_message(text)
|
|
130
|
+
if not chunks:
|
|
131
|
+
return self._error("A notificação Telegram está vazia.", error_type="empty_message")
|
|
132
|
+
message_ids: list[str] = []
|
|
133
|
+
for chunk in chunks:
|
|
134
|
+
result = self._post("sendMessage", {"chat_id": self.chat_id, "text": chunk})
|
|
135
|
+
if not result.ok:
|
|
136
|
+
return result
|
|
137
|
+
message_result = result.data.get("result") if isinstance(result.data, dict) else None
|
|
138
|
+
if isinstance(message_result, dict) and message_result.get("message_id") is not None:
|
|
139
|
+
message_ids.append(str(message_result["message_id"]))
|
|
140
|
+
return IntegrationResult(True, "Notificação enviada pelo Telegram.", {"message_ids": message_ids, "chunks": len(chunks)})
|
|
141
|
+
|
|
142
|
+
def send_notification(self, notification: dict[str, Any]) -> IntegrationResult:
|
|
143
|
+
"""Format and send one persisted Thunderbolt notification."""
|
|
144
|
+
return self.send_message(self.format_notification(notification))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def send_notification_to_telegram(notification: dict[str, Any], settings: dict[str, Any] | None = None) -> IntegrationResult:
|
|
148
|
+
"""Convenience function used by the notification persistence layer."""
|
|
149
|
+
return TelegramGatewayAdapter(settings).send_notification(notification)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = [
|
|
153
|
+
"DEFAULT_TELEGRAM_API_BASE_URL",
|
|
154
|
+
"DEFAULT_TELEGRAM_TIMEOUT",
|
|
155
|
+
"TELEGRAM_MAX_MESSAGE_LENGTH",
|
|
156
|
+
"TelegramGatewayAdapter",
|
|
157
|
+
"send_notification_to_telegram",
|
|
158
|
+
]
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
streamlit>=1.37,<2
|
|
2
|
-
requests>=2.31,<3
|
|
2
|
+
requests[socks]>=2.31,<3
|
|
3
3
|
pandas>=2.0,<3
|
|
4
4
|
imageio-ffmpeg>=0.6,<1
|
|
5
5
|
toml>=0.10,<1
|
|
@@ -12,5 +12,7 @@ mlxtend>=0.23,<1
|
|
|
12
12
|
plotly>=5.18,<7
|
|
13
13
|
seaborn>=0.13,<1
|
|
14
14
|
yt-dlp>=2025.1.15,<2027
|
|
15
|
+
ytmusicapi>=1.12,<2
|
|
16
|
+
pushtunes>=2.15,<3
|
|
15
17
|
matplotlib>=3.7,<4
|
|
16
18
|
kagglehub>=0.3,<1
|