@danhachuel/thunderbolt 0.3.70 → 0.3.72
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/MANUAL-INSTALACAO.md +19 -10
- package/README.md +23 -11
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/app/influencers_ui.py +183 -8
- package/app/main.py +206 -10
- package/hermes_ui/creative_generation.py +39 -0
- package/hermes_ui/influencers.py +19 -0
- package/hermes_ui/media_generation.py +335 -0
- package/integrations/bilibili_upload.py +259 -0
- package/integrations/distrokid_upload.py +227 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Preparação de uploads DistroKid por browser, inspirada no fluxo do musikai.
|
|
2
|
+
|
|
3
|
+
O adapter executa apenas a parte de upload/preenchimento do formulário. A
|
|
4
|
+
submissão final fica sempre manual no browser para evitar publicação acidental.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import http.cookies
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .platforms import IntegrationResult
|
|
18
|
+
|
|
19
|
+
DISTROKID_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg", ".aac"}
|
|
20
|
+
DISTROKID_COVER_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
|
21
|
+
DEFAULT_DISTROKID_URL = "https://distrokid.com/new/"
|
|
22
|
+
_LIVE_SESSIONS: dict[str, Any] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cookie_header_to_context_cookies(cookie_header: str) -> list[dict[str, str]]:
|
|
26
|
+
jar = http.cookies.SimpleCookie()
|
|
27
|
+
jar.load(str(cookie_header or ""))
|
|
28
|
+
return [
|
|
29
|
+
{"name": morsel.key, "value": morsel.value, "domain": ".distrokid.com", "path": "/"}
|
|
30
|
+
for morsel in jar.values()
|
|
31
|
+
if morsel.key and morsel.value
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _safe_path(value: str | Path | None, allowed: set[str], label: str) -> Path | None:
|
|
36
|
+
if not value:
|
|
37
|
+
return None
|
|
38
|
+
path = Path(value).expanduser()
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
raise ValueError(f"{label} não encontrado: {path}")
|
|
41
|
+
if path.suffix.lower() not in allowed:
|
|
42
|
+
raise ValueError(f"Formato de {label.lower()} não suportado: {path.suffix}")
|
|
43
|
+
return path.resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text(value: Any, maximum: int = 500) -> str:
|
|
47
|
+
return str(value or "").strip()[:maximum]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DistroKidAdapter:
|
|
51
|
+
"""Preenche a página de novo lançamento DistroKid e carrega as tracks localmente."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
54
|
+
self.settings = settings or {}
|
|
55
|
+
self.enabled = bool(self.settings.get("distrokid_enabled", False))
|
|
56
|
+
self.cookie = str(self.settings.get("distrokid_cookie") or "").strip()
|
|
57
|
+
self.account = _text(self.settings.get("distrokid_account"), 160)
|
|
58
|
+
self.first_name = _text(self.settings.get("distrokid_first_name"), 120)
|
|
59
|
+
self.last_name = _text(self.settings.get("distrokid_last_name"), 120)
|
|
60
|
+
self.record_label = _text(self.settings.get("distrokid_record_label"), 160)
|
|
61
|
+
self.browser_path = str(self.settings.get("distrokid_browser_path") or os.getenv("THUNDERBOLT_CHROME_PATH") or "").strip()
|
|
62
|
+
self.base_url = str(self.settings.get("distrokid_url") or DEFAULT_DISTROKID_URL).strip() or DEFAULT_DISTROKID_URL
|
|
63
|
+
|
|
64
|
+
def status(self) -> IntegrationResult:
|
|
65
|
+
if not self.enabled:
|
|
66
|
+
return IntegrationResult(False, "DistroKid está desactivado nesta configuração.", {})
|
|
67
|
+
if not self.cookie:
|
|
68
|
+
return IntegrationResult(False, "Configure o cookie de sessão DistroKid para preencher o formulário de upload.", {"missing_fields": ["distrokid_cookie"]})
|
|
69
|
+
cookies = _cookie_header_to_context_cookies(self.cookie)
|
|
70
|
+
if not cookies:
|
|
71
|
+
return IntegrationResult(False, "O cookie DistroKid não tem um formato válido de cabeçalho Cookie.", {"missing_fields": ["distrokid_cookie"]})
|
|
72
|
+
return IntegrationResult(True, "DistroKid configurado para upload manual assistido.", {"account": self.account or "Conta DistroKid", "cookies": len(cookies), "manual_submit": True})
|
|
73
|
+
|
|
74
|
+
def test_connection(self) -> IntegrationResult:
|
|
75
|
+
status = self.status()
|
|
76
|
+
if not status.ok:
|
|
77
|
+
return status
|
|
78
|
+
cookie_header = self.cookie
|
|
79
|
+
try:
|
|
80
|
+
response = requests.get(
|
|
81
|
+
self.base_url,
|
|
82
|
+
headers={"Cookie": cookie_header, "User-Agent": "Mozilla/5.0 Thunderbolt"},
|
|
83
|
+
timeout=30,
|
|
84
|
+
allow_redirects=True,
|
|
85
|
+
)
|
|
86
|
+
except requests.RequestException as exc:
|
|
87
|
+
return IntegrationResult(False, f"Não foi possível contactar o DistroKid: {exc}", {"api": "DistroKid web", "account": self.account or "Conta DistroKid"})
|
|
88
|
+
final_url = str(getattr(response, "url", "") or "")
|
|
89
|
+
authenticated = response.status_code < 400 and "/login" not in final_url.lower() and "login" not in (response.text or "")[:2000].lower()
|
|
90
|
+
if not authenticated:
|
|
91
|
+
return IntegrationResult(False, "A sessão DistroKid foi rejeitada ou expirou. Actualize o cookie no browser e guarde-o novamente.", {"api": "DistroKid web", "status_code": response.status_code, "authenticated": False})
|
|
92
|
+
return IntegrationResult(True, "Chamada DistroKid concluída; a sessão parece válida.", {"api": "DistroKid web", "status_code": response.status_code, "authenticated": True})
|
|
93
|
+
|
|
94
|
+
def prepare_upload(
|
|
95
|
+
self,
|
|
96
|
+
tracks: list[dict[str, Any]],
|
|
97
|
+
*,
|
|
98
|
+
artist: str,
|
|
99
|
+
release_title: str,
|
|
100
|
+
record_label: str = "",
|
|
101
|
+
cover_path: str | Path | None = None,
|
|
102
|
+
genre: str = "",
|
|
103
|
+
) -> IntegrationResult:
|
|
104
|
+
"""Open DistroKid's form, fill metadata and upload tracks; never submit automatically."""
|
|
105
|
+
status = self.status()
|
|
106
|
+
if not status.ok:
|
|
107
|
+
return status
|
|
108
|
+
if not tracks:
|
|
109
|
+
return IntegrationResult(False, "Seleccione pelo menos uma faixa para o upload DistroKid.", {})
|
|
110
|
+
clean_artist = _text(artist, 160)
|
|
111
|
+
clean_release_title = _text(release_title, 160)
|
|
112
|
+
clean_label = _text(record_label or self.record_label, 160)
|
|
113
|
+
if not clean_artist or not clean_release_title:
|
|
114
|
+
return IntegrationResult(False, "Artista e título do lançamento são obrigatórios para o upload DistroKid.", {})
|
|
115
|
+
prepared_tracks: list[dict[str, Any]] = []
|
|
116
|
+
try:
|
|
117
|
+
for item in tracks:
|
|
118
|
+
path = _safe_path(item.get("path"), DISTROKID_AUDIO_EXTENSIONS, "ficheiro de áudio")
|
|
119
|
+
if path is None:
|
|
120
|
+
raise ValueError("Cada faixa DistroKid precisa de um ficheiro de áudio local.")
|
|
121
|
+
prepared_tracks.append({
|
|
122
|
+
"path": path,
|
|
123
|
+
"title": _text(item.get("title") or path.stem, 160),
|
|
124
|
+
"instrumental": bool(item.get("instrumental", False)),
|
|
125
|
+
})
|
|
126
|
+
cover = _safe_path(cover_path, DISTROKID_COVER_EXTENSIONS, "capa")
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
return IntegrationResult(False, str(exc), {"api": "DistroKid web"})
|
|
129
|
+
try:
|
|
130
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
131
|
+
from playwright.sync_api import sync_playwright
|
|
132
|
+
except ImportError as exc:
|
|
133
|
+
return IntegrationResult(False, "A dependência Playwright não está instalada; actualize o Thunderbolt para activar o browser DistroKid.", {"api": "DistroKid web"})
|
|
134
|
+
session_id = f"distrokid-{uuid.uuid4().hex[:12]}"
|
|
135
|
+
try:
|
|
136
|
+
playwright = sync_playwright().start()
|
|
137
|
+
launch_kwargs: dict[str, Any] = {"headless": False}
|
|
138
|
+
if self.browser_path:
|
|
139
|
+
launch_kwargs["executable_path"] = self.browser_path
|
|
140
|
+
else:
|
|
141
|
+
launch_kwargs["channel"] = "chrome"
|
|
142
|
+
browser = playwright.chromium.launch(**launch_kwargs)
|
|
143
|
+
context = browser.new_context(locale="en-US", accept_downloads=True)
|
|
144
|
+
context.add_cookies(_cookie_header_to_context_cookies(self.cookie))
|
|
145
|
+
page = context.new_page()
|
|
146
|
+
page.goto(self.base_url, wait_until="domcontentloaded", timeout=60000)
|
|
147
|
+
page.wait_for_selector("body", state="visible", timeout=30000)
|
|
148
|
+
if "/login" in page.url.lower():
|
|
149
|
+
raise RuntimeError("A sessão DistroKid foi redireccionada para login.")
|
|
150
|
+
self._fill_form(page, prepared_tracks, clean_artist, clean_release_title, clean_label, cover, _text(genre, 120))
|
|
151
|
+
_LIVE_SESSIONS[session_id] = {"playwright": playwright, "browser": browser, "context": context, "page": page}
|
|
152
|
+
return IntegrationResult(True, "Formulário DistroKid aberto e ficheiros carregados. Reveja todos os dados no browser e clique manualmente em Submit.", {"session_id": session_id, "account": self.account or "Conta DistroKid", "tracks": len(prepared_tracks), "manual_submit": True})
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
try:
|
|
155
|
+
playwright.stop() # type: ignore[name-defined]
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
if exc.__class__.__name__ == "Error" or "executable" in str(exc).lower() or "browser" in str(exc).lower():
|
|
159
|
+
return IntegrationResult(False, f"Não foi possível abrir o browser DistroKid: {exc}", {"api": "DistroKid web"})
|
|
160
|
+
if "Timeout" in exc.__class__.__name__ or "timeout" in str(exc).lower():
|
|
161
|
+
return IntegrationResult(False, f"O DistroKid não carregou o formulário a tempo: {exc}", {"api": "DistroKid web"})
|
|
162
|
+
return IntegrationResult(False, f"Upload DistroKid falhou: {exc}", {"api": "DistroKid web"})
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _fill_form(page: Any, tracks: list[dict[str, Any]], artist: str, release_title: str, record_label: str, cover: Path | None, genre: str) -> None:
|
|
166
|
+
try:
|
|
167
|
+
page.locator("#sitetran_select").select_option("en", timeout=5000)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
page.locator("#artistName").fill(artist)
|
|
171
|
+
if record_label:
|
|
172
|
+
try:
|
|
173
|
+
page.locator("#recordLabel").select_option(label=record_label, timeout=8000)
|
|
174
|
+
except Exception:
|
|
175
|
+
try:
|
|
176
|
+
page.locator("#recordLabel").select_option(record_label, timeout=5000)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
try:
|
|
180
|
+
page.locator("#howManySongsOnThisAlbum").select_option(str(len(tracks)), timeout=15000)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
page.wait_for_timeout(1000)
|
|
184
|
+
if cover is not None:
|
|
185
|
+
page.locator("#artwork").set_input_files(str(cover), timeout=15000)
|
|
186
|
+
try:
|
|
187
|
+
page.locator("img.artworkPreview").wait_for(state="visible", timeout=30000)
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
track_nodes = page.locator("input[name^='tracknum_']")
|
|
191
|
+
count = track_nodes.count()
|
|
192
|
+
if count < len(tracks):
|
|
193
|
+
raise RuntimeError(f"O DistroKid apresentou {count} campos de faixa, mas foram seleccionadas {len(tracks)}.")
|
|
194
|
+
for index, track in enumerate(tracks, start=1):
|
|
195
|
+
track_id = track_nodes.nth(index - 1).get_attribute("id") or ""
|
|
196
|
+
track_id = re.sub(r"^tracknum_", "", track_id)
|
|
197
|
+
if not track_id:
|
|
198
|
+
raise RuntimeError(f"Não foi possível encontrar o identificador da faixa {index}.")
|
|
199
|
+
page.locator(f"#title_{track_id}").fill(track["title"])
|
|
200
|
+
page.locator(f"#js-track-upload-{index}").set_input_files(str(track["path"]), timeout=30000)
|
|
201
|
+
page.locator(f"#showFilename_{index}").wait_for(state="visible", timeout=120000)
|
|
202
|
+
if len(tracks) > 1:
|
|
203
|
+
try:
|
|
204
|
+
page.locator("#albumTitleInput").fill(release_title)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
if genre:
|
|
208
|
+
try:
|
|
209
|
+
page.locator("#genrePrimary").select_option(label=genre, timeout=5000)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def close_distrokid_session(session_id: str) -> IntegrationResult:
|
|
215
|
+
session = _LIVE_SESSIONS.pop(str(session_id), None)
|
|
216
|
+
if not session:
|
|
217
|
+
return IntegrationResult(False, "Sessão DistroKid não encontrada ou já encerrada.", {})
|
|
218
|
+
try:
|
|
219
|
+
session["context"].close()
|
|
220
|
+
session["browser"].close()
|
|
221
|
+
session["playwright"].stop()
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
return IntegrationResult(False, f"A sessão DistroKid foi fechada com aviso: {exc}", {"session_id": session_id})
|
|
224
|
+
return IntegrationResult(True, "Sessão DistroKid fechada.", {"session_id": session_id})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["DEFAULT_DISTROKID_URL", "DISTROKID_AUDIO_EXTENSIONS", "DISTROKID_COVER_EXTENSIONS", "DistroKidAdapter", "close_distrokid_session"]
|
package/package.json
CHANGED