@danhachuel/thunderbolt 0.3.96 → 0.3.98
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/app/main.py +113 -13
- package/hermes_ui/music.py +39 -4
- package/hermes_ui/music_generation.py +88 -0
- package/hermes_ui/update_manager.py +108 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -4,6 +4,7 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
import mimetypes
|
|
6
6
|
import re
|
|
7
|
+
import time
|
|
7
8
|
from contextlib import nullcontext
|
|
8
9
|
from datetime import date, datetime, timezone
|
|
9
10
|
import sys
|
|
@@ -42,6 +43,7 @@ from hermes_ui.material_sources import apply_material_source_cards_to_settings,
|
|
|
42
43
|
from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
|
|
43
44
|
from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_cards_for_pool, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
|
|
44
45
|
from hermes_ui.music import create_music_task, list_music_files, list_music_tasks, materialize_suno_audio, request_suno_generation, run_music_task, store_music_file, store_voiceover_file, transition_music_task
|
|
46
|
+
from hermes_ui.music_generation import MUSIC_GENRES, MUSIC_VOCAL_OPTIONS, generate_music_fields
|
|
45
47
|
from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
|
|
46
48
|
from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
|
|
47
49
|
from hermes_ui.influencers import BACKEND_OPTIONS, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, backend_name, backend_status, get_repository, test_backend
|
|
@@ -49,6 +51,7 @@ from hermes_ui.logs import list_logs, logs_to_rows
|
|
|
49
51
|
from hermes_ui.languages import LANGUAGE_CODES, VIDEO_LANGUAGE_CODES, LANGUAGE_FLAG_DATA_URIS, language_code, language_label, ui_language_menu_label, ui_text, video_language_label, video_language_options
|
|
50
52
|
from hermes_ui.api_key_tests import test_apify_credentials, test_influencer_database, test_innertube_api_key, test_kaggle_credentials, test_material_source_credentials, test_media_provider_card, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
|
|
51
53
|
from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
|
|
54
|
+
from hermes_ui.update_manager import check_version, update_to_latest
|
|
52
55
|
|
|
53
56
|
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
54
57
|
from hermes_ui.script_generation import generate_script_document
|
|
@@ -1269,6 +1272,48 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
|
|
|
1269
1272
|
|
|
1270
1273
|
def render_dashboard():
|
|
1271
1274
|
ui_language = current_ui_language()
|
|
1275
|
+
update_area, version_area = st.columns([1.45, 4.55])
|
|
1276
|
+
with update_area:
|
|
1277
|
+
st.markdown(
|
|
1278
|
+
"""
|
|
1279
|
+
<style>
|
|
1280
|
+
div[data-testid="stButton"] button[kind="primary"] {
|
|
1281
|
+
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
|
1282
|
+
color: #ffffff;
|
|
1283
|
+
border: 1px solid #8b5cf6;
|
|
1284
|
+
font-weight: 700;
|
|
1285
|
+
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.28);
|
|
1286
|
+
}
|
|
1287
|
+
div[data-testid="stButton"] button[kind="primary"]:hover {
|
|
1288
|
+
border-color: #c4b5fd;
|
|
1289
|
+
filter: brightness(1.08);
|
|
1290
|
+
}
|
|
1291
|
+
</style>
|
|
1292
|
+
""",
|
|
1293
|
+
unsafe_allow_html=True,
|
|
1294
|
+
)
|
|
1295
|
+
if st.button("Atualizar Versão", key="home_update_version", use_container_width=True, type="primary", icon=":material/system_update:"):
|
|
1296
|
+
with st.spinner("A instalar a versão mais recente…"):
|
|
1297
|
+
st.session_state["home_update_result"] = update_to_latest(APP_VERSION)
|
|
1298
|
+
with version_area:
|
|
1299
|
+
cache_key = "home_update_version_check"
|
|
1300
|
+
checked_at_key = "home_update_version_checked_at"
|
|
1301
|
+
if not st.session_state.get(cache_key) or time.monotonic() - float(st.session_state.get(checked_at_key, 0)) > 300:
|
|
1302
|
+
st.session_state[cache_key] = check_version(APP_VERSION)
|
|
1303
|
+
st.session_state[checked_at_key] = time.monotonic()
|
|
1304
|
+
version_status = st.session_state[cache_key]
|
|
1305
|
+
if version_status.update_available:
|
|
1306
|
+
st.info(f"Nova versão disponível: {version_status.latest_version}. A versão actual é {APP_VERSION or 'desconhecida'}.")
|
|
1307
|
+
elif version_status.error:
|
|
1308
|
+
st.caption(f"Versão actual: {APP_VERSION or 'desconhecida'} · verificação de actualização indisponível.")
|
|
1309
|
+
else:
|
|
1310
|
+
st.caption(f"Versão actual: {APP_VERSION or 'desconhecida'} · já está actualizada ({version_status.latest_version}).")
|
|
1311
|
+
update_result = st.session_state.get("home_update_result")
|
|
1312
|
+
if update_result is not None:
|
|
1313
|
+
if update_result.ok:
|
|
1314
|
+
st.success(update_result.message)
|
|
1315
|
+
else:
|
|
1316
|
+
st.error(update_result.message)
|
|
1272
1317
|
st.title("Thunderbolt")
|
|
1273
1318
|
st.caption(ui_text("Interface local para operação e automação de conteúdo faceless", ui_language))
|
|
1274
1319
|
summary = pipeline_summary()
|
|
@@ -2607,20 +2652,75 @@ def render_music_creation():
|
|
|
2607
2652
|
st.title("Criação de Músicas")
|
|
2608
2653
|
st.caption("Crie apenas áudio. Os pedidos desta página não criam vídeo, não usam o MoneyPrinterTurbo e não entram no Backlog Vídeos.")
|
|
2609
2654
|
settings = read_json("settings.json", {})
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2655
|
+
generated_fields = st.session_state.pop("music_task_generated_fields", None)
|
|
2656
|
+
if isinstance(generated_fields, dict):
|
|
2657
|
+
for state_key, generated_key in (
|
|
2658
|
+
("music_task_title", "title"),
|
|
2659
|
+
("music_task_language", "language"),
|
|
2660
|
+
("music_task_genre", "genre"),
|
|
2661
|
+
("music_task_vocal", "vocal"),
|
|
2662
|
+
("music_task_references", "references"),
|
|
2663
|
+
("music_task_prompt", "prompt"),
|
|
2664
|
+
):
|
|
2665
|
+
st.session_state[state_key] = str(generated_fields.get(generated_key) or "")
|
|
2666
|
+
provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
|
|
2667
|
+
theme = st.text_input("Tema / assunto principal", key="music_task_theme", placeholder="Ex.: uma viagem nocturna pela costa portuguesa")
|
|
2668
|
+
title = st.text_input("Título da música", key="music_task_title")
|
|
2669
|
+
language = st.selectbox(
|
|
2670
|
+
"Idioma da letra/música",
|
|
2671
|
+
list(LANGUAGE_CODES),
|
|
2672
|
+
index=list(LANGUAGE_CODES).index(str(st.session_state.get("music_task_language") or "pt")) if str(st.session_state.get("music_task_language") or "pt") in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt"),
|
|
2673
|
+
format_func=language_label,
|
|
2674
|
+
key="music_task_language",
|
|
2675
|
+
)
|
|
2676
|
+
genre = st.selectbox("Género musical", list(MUSIC_GENRES), key="music_task_genre")
|
|
2677
|
+
vocal = st.selectbox("Vocal", list(MUSIC_VOCAL_OPTIONS), key="music_task_vocal")
|
|
2678
|
+
references = st.text_area(
|
|
2679
|
+
"Referências culturais, paisagens, clima ou artistas similares (opcional)",
|
|
2680
|
+
key="music_task_references",
|
|
2681
|
+
placeholder="Ex.: pôr do sol mediterrânico, estrada molhada, nostalgia suave e arranjos acústicos contemporâneos",
|
|
2682
|
+
height=90,
|
|
2683
|
+
)
|
|
2684
|
+
if st.button("Gerar campos musicais com IA", key="music_task_generate_fields", use_container_width=True, icon=":material/auto_awesome:"):
|
|
2621
2685
|
try:
|
|
2622
|
-
|
|
2623
|
-
|
|
2686
|
+
with st.spinner("A criar título, letra e prompt musical originais…"):
|
|
2687
|
+
generated = generate_music_fields(
|
|
2688
|
+
settings,
|
|
2689
|
+
theme=theme,
|
|
2690
|
+
language=language,
|
|
2691
|
+
genre=genre,
|
|
2692
|
+
vocal=vocal,
|
|
2693
|
+
references=references,
|
|
2694
|
+
)
|
|
2695
|
+
st.session_state["music_task_generated_fields"] = generated
|
|
2696
|
+
st.rerun()
|
|
2697
|
+
except CreativeGenerationError as exc:
|
|
2698
|
+
st.error(str(exc))
|
|
2699
|
+
prompt = st.text_area(
|
|
2700
|
+
"Prompt musical",
|
|
2701
|
+
placeholder="Use Gerar campos musicais com IA para criar letra e estilo completos, ou escreva um prompt próprio.",
|
|
2702
|
+
key="music_task_prompt",
|
|
2703
|
+
height=300,
|
|
2704
|
+
)
|
|
2705
|
+
lyria_model = ""
|
|
2706
|
+
if provider_label == "Google Lyria":
|
|
2707
|
+
models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
|
|
2708
|
+
configured = str(settings.get("lyria_model") or models[0])
|
|
2709
|
+
lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
|
|
2710
|
+
if st.button("Gerar Música", key="music_task_submit", type="primary", use_container_width=True, icon=":material/music_note:"):
|
|
2711
|
+
try:
|
|
2712
|
+
task = create_music_task(
|
|
2713
|
+
provider_label,
|
|
2714
|
+
prompt,
|
|
2715
|
+
title,
|
|
2716
|
+
lyria_model,
|
|
2717
|
+
language=language,
|
|
2718
|
+
genre=genre,
|
|
2719
|
+
vocal=vocal,
|
|
2720
|
+
references=references,
|
|
2721
|
+
theme=theme,
|
|
2722
|
+
)
|
|
2723
|
+
st.success(f"Música criada no Music Backlog: {task['title']}")
|
|
2624
2724
|
st.rerun()
|
|
2625
2725
|
except ValueError as exc:
|
|
2626
2726
|
st.error(str(exc))
|
package/hermes_ui/music.py
CHANGED
|
@@ -97,7 +97,14 @@ def materialize_suno_audio(data: dict[str, Any], title: str = "suno-generated.mp
|
|
|
97
97
|
return store_music_file(title, response.content)
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def request_suno_generation(
|
|
100
|
+
def request_suno_generation(
|
|
101
|
+
settings: dict[str, Any],
|
|
102
|
+
prompt: str,
|
|
103
|
+
title: str = "",
|
|
104
|
+
duration_seconds: int = 120,
|
|
105
|
+
*,
|
|
106
|
+
make_instrumental: bool = True,
|
|
107
|
+
) -> dict[str, Any]:
|
|
101
108
|
"""Request music from a configured Suno-compatible endpoint.
|
|
102
109
|
|
|
103
110
|
Suno-compatible deployments expose different endpoint paths; the UI therefore
|
|
@@ -109,7 +116,12 @@ def request_suno_generation(settings: dict[str, Any], prompt: str, title: str =
|
|
|
109
116
|
if not api_key or not base_url:
|
|
110
117
|
return {"ok": False, "message": "Configure Suno API Key e Suno API Base URL em Configurações antes de solicitar uma música.", "data": {}}
|
|
111
118
|
url = endpoint if endpoint.startswith("http") else f"{base_url}/{endpoint.lstrip('/')}"
|
|
112
|
-
payload = {
|
|
119
|
+
payload = {
|
|
120
|
+
"prompt": prompt.strip(),
|
|
121
|
+
"title": title.strip(),
|
|
122
|
+
"duration": max(120, int(duration_seconds)),
|
|
123
|
+
"make_instrumental": bool(make_instrumental),
|
|
124
|
+
}
|
|
113
125
|
try:
|
|
114
126
|
response = requests.post(url, json=payload, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=30)
|
|
115
127
|
if response.status_code >= 400:
|
|
@@ -132,7 +144,18 @@ def _save_music_tasks(tasks: list[dict[str, Any]]) -> None:
|
|
|
132
144
|
storage.write_json("music_tasks.json", tasks)
|
|
133
145
|
|
|
134
146
|
|
|
135
|
-
def create_music_task(
|
|
147
|
+
def create_music_task(
|
|
148
|
+
provider: str,
|
|
149
|
+
prompt: str,
|
|
150
|
+
title: str,
|
|
151
|
+
model: str = "",
|
|
152
|
+
*,
|
|
153
|
+
language: str = "",
|
|
154
|
+
genre: str = "",
|
|
155
|
+
vocal: str = "",
|
|
156
|
+
references: str = "",
|
|
157
|
+
theme: str = "",
|
|
158
|
+
) -> dict[str, Any]:
|
|
136
159
|
"""Enqueue one audio-only generation; no video task or video worker is used."""
|
|
137
160
|
cleaned_prompt = str(prompt or "").strip()
|
|
138
161
|
if not cleaned_prompt:
|
|
@@ -145,6 +168,12 @@ def create_music_task(provider: str, prompt: str, title: str, model: str = "") -
|
|
|
145
168
|
"model": str(model or "").strip(),
|
|
146
169
|
"title": str(title or "Música sem título").strip() or "Música sem título",
|
|
147
170
|
"prompt": cleaned_prompt,
|
|
171
|
+
"language": str(language or "").strip(),
|
|
172
|
+
"genre": str(genre or "").strip(),
|
|
173
|
+
"vocal": str(vocal or "").strip(),
|
|
174
|
+
"references": str(references or "").strip(),
|
|
175
|
+
"theme": str(theme or "").strip(),
|
|
176
|
+
"duration_seconds": 120,
|
|
148
177
|
"state": "to_do",
|
|
149
178
|
"stage": "music_generation",
|
|
150
179
|
"progress": 0,
|
|
@@ -240,7 +269,13 @@ def run_music_task(task_id: str, settings: dict[str, Any]) -> dict[str, Any] | N
|
|
|
240
269
|
result = request_lyria_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""), str(task.get("model") or ""))
|
|
241
270
|
audio_path = str((result.get("data") or {}).get("audio_path") or "")
|
|
242
271
|
else:
|
|
243
|
-
result = request_suno_generation(
|
|
272
|
+
result = request_suno_generation(
|
|
273
|
+
settings,
|
|
274
|
+
str(task.get("prompt") or ""),
|
|
275
|
+
str(task.get("title") or ""),
|
|
276
|
+
int(task.get("duration_seconds") or 120),
|
|
277
|
+
make_instrumental=not bool(str(task.get("vocal") or "").strip()),
|
|
278
|
+
)
|
|
244
279
|
try:
|
|
245
280
|
output = materialize_suno_audio(result.get("data") or {}, str(task.get("title") or "suno-generated.mp3")) if result.get("ok") else None
|
|
246
281
|
audio_path = str(output or "")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Structured, original music briefs generated through the configured LLM pool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .creative_generation import CreativeGenerationError, _chat_json
|
|
9
|
+
from .languages import language_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MUSIC_GENRES = (
|
|
13
|
+
"Pop", "Rock", "Hip Hop", "EDM (Eletrônica)", "Techno", "Country", "Folk", "Indie", "K-pop", "Jazz",
|
|
14
|
+
"Reggae", "Classical", "Metal", "Punk", "Afrobeat (Nigéria)", "Ambient (Reino Unido)", "Anime (Japão)",
|
|
15
|
+
"Arabic Pop / Khaleeji (Mundo Árabe)", "Austropop (Áustria)", "Ballad (Internacional)", "Banda (México)",
|
|
16
|
+
"Blues (EUA)", "Bollywood / Indian (Índia)", "City Pop (Japão)", "Corridos Tumbados (México)", "C-pop (China)",
|
|
17
|
+
"Cumbia (Colômbia)", "Dangdut (Indonésia)", "Dansband (Suécia)", "Deutschrap (Alemanha)",
|
|
18
|
+
"Disco Polo (Polônia)", "Drum & Bass (Reino Unido)", "Entechno (Grécia)", "Fado (Portugal)",
|
|
19
|
+
"Flamenco (Espanha)", "Forró (Brasil)", "Funk (Brasil)", "Fusion (Internacional)", "Gamolan (Indonésia)",
|
|
20
|
+
"Gospel (EUA)", "Grime (Reino Unido)", "Hardstyle (Países Baixos)", "J-Pop (Japão)", "J-Rock (Japão)",
|
|
21
|
+
"K-Hip Hop (Coreia do Sul)", "Kizomba (Angola)", "Klapa (Croácia)", "Klezmer (Judeu / Europa Oriental)",
|
|
22
|
+
"Laïko (Grécia)", "Latin (América Latina)", "Mandopop (China / Taiwan)", "Māori (Nova Zelândia)", "MPB (Brasil)",
|
|
23
|
+
"Mundart (Dialect Pop) (Suíça)", "Pasifika (Pacífico)", "Pimba (Portugal)", "Pop Sueco (Suécia)", "R&B (EUA)",
|
|
24
|
+
"Rap Francês (França)", "Reggaeton (Porto Rico)", "Regional Mexicano (México)", "Samba / Pagode (Brasil)",
|
|
25
|
+
"Schlager (Alemanha)", "Sertanejo (Brasil)", "Trot (Coreia do Sul)", "Urbano / Trap (América Latina)",
|
|
26
|
+
"Variété (França)", "Vocaloid (Japão)",
|
|
27
|
+
)
|
|
28
|
+
MUSIC_VOCAL_OPTIONS = ("Masculino", "Feminina", "Coral")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_music_fields(
|
|
32
|
+
settings: dict[str, Any],
|
|
33
|
+
*,
|
|
34
|
+
theme: str,
|
|
35
|
+
language: str,
|
|
36
|
+
genre: str,
|
|
37
|
+
vocal: str,
|
|
38
|
+
references: str = "",
|
|
39
|
+
) -> dict[str, str]:
|
|
40
|
+
"""Generate original, ready-to-use music fields through the active LLM pool."""
|
|
41
|
+
cleaned_theme = str(theme or "").strip()
|
|
42
|
+
if not cleaned_theme:
|
|
43
|
+
raise CreativeGenerationError("Escreva o tema ou assunto principal antes de gerar os campos musicais com IA.")
|
|
44
|
+
selected_genre = str(genre or "").strip()
|
|
45
|
+
selected_vocal = str(vocal or "").strip()
|
|
46
|
+
selected_language = language_code(language)
|
|
47
|
+
system = (
|
|
48
|
+
"És um compositor e produtor musical. Cria uma canção inteiramente original, sem reproduzir letras, melodias, "
|
|
49
|
+
"frases distintivas ou a identidade de artistas existentes. As referências fornecidas servem apenas para atributos "
|
|
50
|
+
"de alto nível, como instrumentação, clima, contexto cultural e energia. Responde apenas com JSON válido contendo "
|
|
51
|
+
"as chaves title, language, genre, vocal, cultural_references e music_prompt. "
|
|
52
|
+
"music_prompt deve ser Markdown legível, pronto para uma ferramenta de geração musical, com exactamente estas secções: "
|
|
53
|
+
"# 1. Nome da Música, # 2. Idioma da Música, # 3. Letra / Lyrics, # 4. Estilo / Style Prompt. "
|
|
54
|
+
"A letra deve ser original, incluir [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], "
|
|
55
|
+
"[Instrumental Solo], [Final Chorus] e [Outro], ter uma estrutura que vise pelo menos dois minutos, e o estilo deve "
|
|
56
|
+
"informar subgénero, vocal, instrumentação, BPM, clima, produção e estrutura."
|
|
57
|
+
)
|
|
58
|
+
user = json.dumps(
|
|
59
|
+
{
|
|
60
|
+
"theme": cleaned_theme,
|
|
61
|
+
"selected_language_code": selected_language,
|
|
62
|
+
"selected_genre": selected_genre,
|
|
63
|
+
"selected_vocal": selected_vocal,
|
|
64
|
+
"cultural_landscape_weather_or_artist_references": str(references or "").strip(),
|
|
65
|
+
"available_genres": list(MUSIC_GENRES),
|
|
66
|
+
"available_vocals": list(MUSIC_VOCAL_OPTIONS),
|
|
67
|
+
"requirements": {
|
|
68
|
+
"minimum_target_duration_seconds": 120,
|
|
69
|
+
"preserve_selected_language_genre_and_vocal_when_present": True,
|
|
70
|
+
"output_original_content_only": True,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
ensure_ascii=False,
|
|
74
|
+
)
|
|
75
|
+
result = _chat_json(settings, system, user)
|
|
76
|
+
music_prompt = str(result.get("music_prompt") or "").strip()
|
|
77
|
+
if not music_prompt:
|
|
78
|
+
raise CreativeGenerationError("O provider LLM não devolveu um prompt musical válido.")
|
|
79
|
+
generated_genre = str(result.get("genre") or selected_genre).strip()
|
|
80
|
+
generated_vocal = str(result.get("vocal") or selected_vocal).strip()
|
|
81
|
+
return {
|
|
82
|
+
"title": str(result.get("title") or cleaned_theme).strip()[:180],
|
|
83
|
+
"language": language_code(result.get("language"), default=selected_language),
|
|
84
|
+
"genre": generated_genre if generated_genre in MUSIC_GENRES else selected_genre,
|
|
85
|
+
"vocal": generated_vocal if generated_vocal in MUSIC_VOCAL_OPTIONS else selected_vocal,
|
|
86
|
+
"references": str(result.get("cultural_references") or references or "").strip()[:800],
|
|
87
|
+
"prompt": music_prompt[:16000],
|
|
88
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Verificação e actualização local da versão distribuída pelo NPM.
|
|
2
|
+
|
|
3
|
+
O módulo não recebe nem manipula credenciais. A instalação só é iniciada após o
|
|
4
|
+
clique explícito do utilizador na interface local.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PACKAGE_NAME = "@danhachuel/thunderbolt"
|
|
18
|
+
REGISTRY_URL = "https://registry.npmjs.org/@danhachuel/thunderbolt/latest"
|
|
19
|
+
UPDATE_TIMEOUT_SECONDS = 20 * 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class VersionCheck:
|
|
24
|
+
"""Version information displayed on the local home page."""
|
|
25
|
+
|
|
26
|
+
current_version: str
|
|
27
|
+
latest_version: str = ""
|
|
28
|
+
error: str = ""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def update_available(self) -> bool:
|
|
32
|
+
return bool(self.latest_version and self.current_version and self.latest_version != self.current_version)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class UpdateResult:
|
|
37
|
+
"""Sanitised result of an explicit local package update request."""
|
|
38
|
+
|
|
39
|
+
ok: bool
|
|
40
|
+
latest_version: str = ""
|
|
41
|
+
message: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def latest_package_version(*, timeout: int = 8, get: Callable[..., Any] = requests.get) -> str:
|
|
45
|
+
"""Return the latest public package version without sending local configuration."""
|
|
46
|
+
response = get(REGISTRY_URL, timeout=timeout)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
payload = response.json()
|
|
49
|
+
version = str(payload.get("version") or "").strip() if isinstance(payload, dict) else ""
|
|
50
|
+
if not version:
|
|
51
|
+
raise ValueError("O registry NPM não devolveu uma versão válida.")
|
|
52
|
+
return version
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_version(current_version: str, *, timeout: int = 8, get: Callable[..., Any] = requests.get) -> VersionCheck:
|
|
56
|
+
"""Fetch only the package metadata needed for the version badge."""
|
|
57
|
+
current = str(current_version or "").strip()
|
|
58
|
+
try:
|
|
59
|
+
return VersionCheck(current_version=current, latest_version=latest_package_version(timeout=timeout, get=get))
|
|
60
|
+
except (requests.RequestException, ValueError) as exc:
|
|
61
|
+
return VersionCheck(current_version=current, error=f"Não foi possível verificar actualizações agora ({type(exc).__name__}).")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def update_command() -> list[str]:
|
|
65
|
+
"""Build the same cross-platform install command documented for Thunderbolt."""
|
|
66
|
+
executable = "npx.cmd" if os.name == "nt" else "npx"
|
|
67
|
+
return [executable, "--yes", "--prefer-online", PACKAGE_NAME, "install"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def update_to_latest(
|
|
71
|
+
current_version: str,
|
|
72
|
+
*,
|
|
73
|
+
timeout: int = UPDATE_TIMEOUT_SECONDS,
|
|
74
|
+
get: Callable[..., Any] = requests.get,
|
|
75
|
+
run: Callable[..., Any] = subprocess.run,
|
|
76
|
+
) -> UpdateResult:
|
|
77
|
+
"""Install the latest package only after an explicit UI action.
|
|
78
|
+
|
|
79
|
+
The running local process keeps its current code until it is restarted. No
|
|
80
|
+
subprocess output is returned to the UI, preventing accidental display of
|
|
81
|
+
environment values from third-party installers.
|
|
82
|
+
"""
|
|
83
|
+
status = check_version(current_version, get=get)
|
|
84
|
+
if status.error:
|
|
85
|
+
return UpdateResult(False, message=status.error)
|
|
86
|
+
if not status.update_available:
|
|
87
|
+
return UpdateResult(True, latest_version=status.latest_version, message="O Thunderbolt já está na versão mais recente.")
|
|
88
|
+
try:
|
|
89
|
+
completed = run(
|
|
90
|
+
update_command(),
|
|
91
|
+
stdin=subprocess.DEVNULL,
|
|
92
|
+
stdout=subprocess.DEVNULL,
|
|
93
|
+
stderr=subprocess.DEVNULL,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
check=False,
|
|
96
|
+
)
|
|
97
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
98
|
+
return UpdateResult(False, latest_version=status.latest_version, message=f"Não foi possível concluir a actualização ({type(exc).__name__}).")
|
|
99
|
+
if int(getattr(completed, "returncode", 1)) != 0:
|
|
100
|
+
return UpdateResult(False, latest_version=status.latest_version, message="A actualização não foi concluída. Feche processos Thunderbolt em execução e tente novamente.")
|
|
101
|
+
return UpdateResult(
|
|
102
|
+
True,
|
|
103
|
+
latest_version=status.latest_version,
|
|
104
|
+
message=(
|
|
105
|
+
f"A versão {status.latest_version} foi instalada. Reinicie o Thunderbolt para abrir a versão nova; "
|
|
106
|
+
"os dados e configurações locais foram preservados."
|
|
107
|
+
),
|
|
108
|
+
)
|
package/package.json
CHANGED