@danhachuel/thunderbolt 0.2.18 → 0.2.20
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 +28 -4
- package/README.md +33 -7
- package/app/main.py +472 -15
- package/hermes_ui/domain.py +36 -0
- package/hermes_ui/mcp.py +145 -0
- package/hermes_ui/music.py +82 -0
- package/hermes_ui/storage.py +56 -1
- package/hermes_ui/voice_preview.py +119 -0
- package/integrations/data/azure_voices.json +1326 -0
- package/integrations/platforms.py +114 -44
- package/integrations/youtube_direct_upload.py +169 -0
- package/package.json +3 -1
- package/requirements.txt +1 -0
- package/scripts/cli.mjs +4 -1
- package/scripts/install.mjs +4 -1
- package/seed/skills/moneyprinterturbo-video.md +132 -0
package/app/main.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
8
|
+
import requests
|
|
7
9
|
import streamlit as st
|
|
8
10
|
|
|
9
11
|
ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -14,14 +16,90 @@ try:
|
|
|
14
16
|
except (OSError, json.JSONDecodeError):
|
|
15
17
|
APP_VERSION = ""
|
|
16
18
|
|
|
17
|
-
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, pipeline_summary, transition_task, update_channel
|
|
19
|
+
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, transition_task, update_channel
|
|
18
20
|
from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
19
21
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
20
22
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
23
|
+
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, read_packaged_skill, update_integration
|
|
24
|
+
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
25
|
+
from hermes_ui.voice_preview import DEFAULT_SAMPLE, synthesize_preview
|
|
21
26
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
27
|
+
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
22
28
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
23
29
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
24
30
|
|
|
31
|
+
AI_STYLE_OPTIONS = [
|
|
32
|
+
"Natural Realista",
|
|
33
|
+
"Cocomelon style",
|
|
34
|
+
"Retro 90s Cartoon",
|
|
35
|
+
"Wool sculpture miniatures",
|
|
36
|
+
"LEGO Style",
|
|
37
|
+
"Paper cutout style",
|
|
38
|
+
"Anime Style",
|
|
39
|
+
"Studio Ghibli Style",
|
|
40
|
+
"Stop Motion Style (Massinha)",
|
|
41
|
+
"Ukiyo Style",
|
|
42
|
+
"Pixel Animation",
|
|
43
|
+
"Pixar Style",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
WIDE_STYLE_OPTIONS = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
|
|
47
|
+
|
|
48
|
+
VIDEO_LANGUAGE_OPTIONS = [
|
|
49
|
+
"00 – Apenas Música de Fundo (Sem Falas)",
|
|
50
|
+
"01 – Inglês",
|
|
51
|
+
"02 – Norueguês",
|
|
52
|
+
"03 – Dinamarquês",
|
|
53
|
+
"04 – Sueco",
|
|
54
|
+
"05 – Holandês",
|
|
55
|
+
"06 – Alemão",
|
|
56
|
+
"07 – Luxemburguês",
|
|
57
|
+
"08 – Finlandês",
|
|
58
|
+
"09 – Hebraico",
|
|
59
|
+
"10 – Japonês",
|
|
60
|
+
"11 – Árabe (Golfo)",
|
|
61
|
+
"12 – Islandês",
|
|
62
|
+
"13 – Espanhol (Espanha)",
|
|
63
|
+
"14 – Francês",
|
|
64
|
+
"15 – Italiano",
|
|
65
|
+
"16 – Coreano",
|
|
66
|
+
"16 – Irlandês",
|
|
67
|
+
"17 – Estoniano",
|
|
68
|
+
"18 – Grego",
|
|
69
|
+
"19 – Esloveno",
|
|
70
|
+
"20 – Polonês",
|
|
71
|
+
"21 – Tcheco",
|
|
72
|
+
"22 – Lituano",
|
|
73
|
+
"23 – Português (Portugal)",
|
|
74
|
+
"24 – Eslovaco",
|
|
75
|
+
"25 – Letão",
|
|
76
|
+
"26 – Ucraniano",
|
|
77
|
+
"27 – Húngaro",
|
|
78
|
+
"28 – Afrikaans",
|
|
79
|
+
"29 – Turco",
|
|
80
|
+
"30 – Romeno",
|
|
81
|
+
"31 – Russo",
|
|
82
|
+
"32 – Croata",
|
|
83
|
+
"33 – Árabe (Magreb)",
|
|
84
|
+
"34 – Sérvio",
|
|
85
|
+
"35 – Búlgaro",
|
|
86
|
+
"36 – Português (Brasil)",
|
|
87
|
+
"37 – Cantonês",
|
|
88
|
+
"38 – Persa (Farsi)",
|
|
89
|
+
"39 – Mandarim",
|
|
90
|
+
"40 – Malaio",
|
|
91
|
+
"41 – Espanhol (LatAm)",
|
|
92
|
+
"42 – Vietnamita",
|
|
93
|
+
"43 – Filipino (Tagalog)",
|
|
94
|
+
"44 – Indonésio",
|
|
95
|
+
"45 – Malayalam",
|
|
96
|
+
"46 – Tailandês",
|
|
97
|
+
"47 – Télugo",
|
|
98
|
+
"48 – Tamil",
|
|
99
|
+
"49 – Bengali",
|
|
100
|
+
"50 – Hausa",
|
|
101
|
+
]
|
|
102
|
+
|
|
25
103
|
ensure_storage()
|
|
26
104
|
st.set_page_config(page_title="Thunderbolt", page_icon="T", layout="wide", initial_sidebar_state="expanded")
|
|
27
105
|
|
|
@@ -36,8 +114,10 @@ st.markdown("""
|
|
|
36
114
|
[data-testid="stSidebar"] .tb-brand-name { color:#f4f8fb; font-size:1.38rem; line-height:1.15; font-weight:750; letter-spacing:-0.02em; }
|
|
37
115
|
[data-testid="stSidebar"] .tb-brand-version { color:#8ba6bb; font-size:0.92rem; line-height:1; font-weight:500; }
|
|
38
116
|
[data-testid="stSidebar"] [data-testid="stButton"] { margin:0.025rem 0 !important; }
|
|
39
|
-
[data-testid="stSidebar"] [data-testid="stButton"] button { min-height:1.72rem; height:1.72rem; justify-content:flex-start; text-align:left; padding:0.10rem 0.52rem; border-radius:7px; border:1px solid transparent; font-size:0.86rem; font-weight:550; }
|
|
40
|
-
[data-testid="stSidebar"] [data-testid="stButton"]
|
|
117
|
+
[data-testid="stSidebar"] [data-testid="stButton"] button { min-height:1.72rem; height:1.72rem; justify-content:flex-start !important; text-align:left !important; padding:0.10rem 0.52rem; border-radius:7px; border:1px solid transparent; font-size:0.86rem; font-weight:550; }
|
|
118
|
+
[data-testid="stSidebar"] [data-testid="stButton"] button > div { width:100% !important; justify-content:flex-start !important; text-align:left !important; }
|
|
119
|
+
[data-testid="stSidebar"] [data-testid="stButton"] button [data-testid="stMarkdownContainer"] { flex:1 1 auto !important; width:100% !important; text-align:left !important; }
|
|
120
|
+
[data-testid="stSidebar"] [data-testid="stButton"] p { margin:0; line-height:1; width:100%; text-align:left !important; }
|
|
41
121
|
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] { background:transparent; color:#e7edf2; }
|
|
42
122
|
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"]:hover { background:#1c252e; border-color:#2d3944; color:#ffffff; }
|
|
43
123
|
[data-testid="stSidebar"] [data-testid="stBaseButton-primary"] { background:#292929; color:#ffffff; border-color:#3a3a3a; }
|
|
@@ -66,6 +146,37 @@ def channel_options() -> list[dict]:
|
|
|
66
146
|
return [c for c in read_json("channels.json", []) if c.get("active", True)]
|
|
67
147
|
|
|
68
148
|
|
|
149
|
+
def blueprint_catalog() -> list[tuple[str, str]]:
|
|
150
|
+
options = [("", "Sem Blueprint padrão")]
|
|
151
|
+
for path in list_blueprint_files():
|
|
152
|
+
try:
|
|
153
|
+
data = load_blueprint_file(path)
|
|
154
|
+
identifier = str(data.get("id") or path.stem)
|
|
155
|
+
label = str(data.get("name") or path.stem)
|
|
156
|
+
options.append((identifier, label))
|
|
157
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
158
|
+
continue
|
|
159
|
+
return options
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def valid_hhmm(value: str) -> bool:
|
|
163
|
+
return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def voice_catalog(current: str = "") -> list[str]:
|
|
167
|
+
voices = [""]
|
|
168
|
+
voice_file = ROOT / "integrations" / "data" / "azure_voices.json"
|
|
169
|
+
try:
|
|
170
|
+
voice_data = json.loads(voice_file.read_text(encoding="utf-8"))
|
|
171
|
+
voices.extend(f"{item.get('name', '')}-{item.get('gender', '')}" for item in voice_data if item.get("name"))
|
|
172
|
+
except (OSError, json.JSONDecodeError):
|
|
173
|
+
pass
|
|
174
|
+
for candidate in (current, "en-US-AriaNeural-Female", "pt-BR-FranciscaNeural-Female", "pt-BR-AntonioNeural-Male"):
|
|
175
|
+
if candidate and candidate not in voices:
|
|
176
|
+
voices.append(candidate)
|
|
177
|
+
return voices
|
|
178
|
+
|
|
179
|
+
|
|
69
180
|
def render_dashboard():
|
|
70
181
|
st.title("Thunderbolt")
|
|
71
182
|
st.caption("Interface local para operação e automação de conteúdo faceless")
|
|
@@ -223,6 +334,13 @@ def render_channels():
|
|
|
223
334
|
if st.session_state.get("yt_message"):
|
|
224
335
|
(st.success if st.session_state.get("yt_ok") else st.warning)(st.session_state["yt_message"])
|
|
225
336
|
imported = st.session_state.get("yt_import", {})
|
|
337
|
+
blueprint_items = blueprint_catalog()
|
|
338
|
+
blueprint_ids = [item[0] for item in blueprint_items]
|
|
339
|
+
blueprint_labels = {item[0]: item[1] for item in blueprint_items}
|
|
340
|
+
imported_blueprint = imported.get("default_blueprint_id") or imported.get("blueprint_id", "")
|
|
341
|
+
if imported_blueprint not in blueprint_ids:
|
|
342
|
+
blueprint_ids.append(imported_blueprint)
|
|
343
|
+
blueprint_labels[imported_blueprint] = imported_blueprint or "Sem Blueprint padrão"
|
|
226
344
|
if imported:
|
|
227
345
|
st.caption("Dados encontrados. Reveja ou edite os campos antes de guardar.")
|
|
228
346
|
with st.form("channel_import_form"):
|
|
@@ -230,8 +348,14 @@ def render_channels():
|
|
|
230
348
|
url = st.text_input("URL", value=imported.get("url", source if source.startswith("http") else ""), key="yt_import_url")
|
|
231
349
|
handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
|
|
232
350
|
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="yt_import_language")
|
|
233
|
-
style = st.selectbox("Estilo wide", ["
|
|
234
|
-
blueprint = st.
|
|
351
|
+
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="yt_import_style")
|
|
352
|
+
blueprint = st.selectbox("Blueprint padrão do canal", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
|
|
353
|
+
voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
|
|
354
|
+
current_voice = imported.get("default_voice") or imported.get("voice", "")
|
|
355
|
+
voice = st.selectbox("Voz padrão do canal", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
|
|
356
|
+
delegated_session_id = st.text_input("DELEGATED_SESSION_ID", value=imported.get("delegated_session_id", ""), type="password", key="yt_import_delegated_session_id")
|
|
357
|
+
automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
|
|
358
|
+
automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
|
|
235
359
|
description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
|
|
236
360
|
metrics = st.columns(3)
|
|
237
361
|
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
|
|
@@ -241,14 +365,22 @@ def render_channels():
|
|
|
241
365
|
if submitted:
|
|
242
366
|
if not name.strip():
|
|
243
367
|
st.error("Informe o nome do canal antes de guardar.")
|
|
368
|
+
elif not valid_hhmm(automation_time):
|
|
369
|
+
st.error("O horário diário deve estar no formato HH:MM, por exemplo 08:30.")
|
|
244
370
|
else:
|
|
245
371
|
metadata = {
|
|
246
372
|
**imported,
|
|
247
373
|
"handle": handle.strip(),
|
|
248
374
|
"description": description.strip(),
|
|
249
375
|
"language": language,
|
|
250
|
-
"style_wide": style,
|
|
376
|
+
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
251
377
|
"blueprint_id": blueprint.strip(),
|
|
378
|
+
"default_blueprint_id": blueprint.strip(),
|
|
379
|
+
"default_voice": voice.strip(),
|
|
380
|
+
"voice": voice.strip(),
|
|
381
|
+
"delegated_session_id": delegated_session_id.strip(),
|
|
382
|
+
"automation_on": bool(automation_on),
|
|
383
|
+
"automation_time": automation_time.strip() if valid_hhmm(automation_time) else "00:00",
|
|
252
384
|
"subscriber_count": int(subscriber_count) or None,
|
|
253
385
|
"video_count": int(video_count) or None,
|
|
254
386
|
"view_count": int(view_count) or None,
|
|
@@ -268,8 +400,16 @@ def render_channels():
|
|
|
268
400
|
handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
|
|
269
401
|
description = st.text_area("Descrição", key="manual_channel_description")
|
|
270
402
|
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
|
|
271
|
-
style = st.selectbox("Estilo wide", ["
|
|
272
|
-
|
|
403
|
+
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
|
|
404
|
+
manual_blueprint_items = blueprint_catalog()
|
|
405
|
+
manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
|
|
406
|
+
manual_blueprint_labels = {item[0]: item[1] for item in manual_blueprint_items}
|
|
407
|
+
blueprint = st.selectbox("Blueprint padrão do canal", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
|
|
408
|
+
voice_options = voice_catalog()
|
|
409
|
+
voice = st.selectbox("Voz padrão do canal", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
|
|
410
|
+
delegated_session_id = st.text_input("DELEGATED_SESSION_ID", type="password", key="manual_channel_delegated_session_id")
|
|
411
|
+
automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
|
|
412
|
+
automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="manual_channel_automation_time")
|
|
273
413
|
thumbnail_url = st.text_input("URL da imagem do canal", key="manual_channel_thumbnail")
|
|
274
414
|
metrics = st.columns(3)
|
|
275
415
|
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=0, key="manual_channel_subscribers")
|
|
@@ -279,13 +419,21 @@ def render_channels():
|
|
|
279
419
|
if submitted:
|
|
280
420
|
if not name.strip():
|
|
281
421
|
st.error("Informe o nome do canal.")
|
|
422
|
+
elif not valid_hhmm(automation_time):
|
|
423
|
+
st.error("O horário diário deve estar no formato HH:MM, por exemplo 08:30.")
|
|
282
424
|
else:
|
|
283
425
|
channel = create_channel(name, url, {
|
|
284
426
|
"handle": handle.strip(),
|
|
285
427
|
"description": description.strip(),
|
|
286
428
|
"language": language,
|
|
287
|
-
"style_wide": style,
|
|
429
|
+
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
288
430
|
"blueprint_id": blueprint.strip(),
|
|
431
|
+
"default_blueprint_id": blueprint.strip(),
|
|
432
|
+
"default_voice": voice.strip(),
|
|
433
|
+
"voice": voice.strip(),
|
|
434
|
+
"delegated_session_id": delegated_session_id.strip(),
|
|
435
|
+
"automation_on": bool(automation_on),
|
|
436
|
+
"automation_time": automation_time.strip(),
|
|
289
437
|
"thumbnail_url": thumbnail_url.strip(),
|
|
290
438
|
"subscriber_count": int(subscriber_count) or None,
|
|
291
439
|
"video_count": int(video_count) or None,
|
|
@@ -318,6 +466,26 @@ def render_channels():
|
|
|
318
466
|
active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel['id']}")
|
|
319
467
|
if active != channel.get("active"):
|
|
320
468
|
update_channel(channel["id"], {"active": active})
|
|
469
|
+
st.rerun()
|
|
470
|
+
delete_key = f"delete_pending_{channel['id']}"
|
|
471
|
+
if not st.session_state.get(delete_key, False):
|
|
472
|
+
if st.button("Apagar canal", key=f"delete_{channel['id']}", use_container_width=True):
|
|
473
|
+
st.session_state[delete_key] = True
|
|
474
|
+
st.rerun()
|
|
475
|
+
else:
|
|
476
|
+
st.warning("As tarefas, vídeos e artefactos relacionados serão preservados.")
|
|
477
|
+
confirm_col, cancel_col = st.columns(2)
|
|
478
|
+
with confirm_col:
|
|
479
|
+
if st.button("Confirmar apagar", key=f"confirm_delete_{channel['id']}", type="primary", use_container_width=True):
|
|
480
|
+
removed = delete_channel(channel["id"])
|
|
481
|
+
st.session_state.pop(delete_key, None)
|
|
482
|
+
if removed:
|
|
483
|
+
st.success(f"Canal {removed.get('name', 'seleccionado')} apagado.")
|
|
484
|
+
st.rerun()
|
|
485
|
+
with cancel_col:
|
|
486
|
+
if st.button("Cancelar", key=f"cancel_delete_{channel['id']}", use_container_width=True):
|
|
487
|
+
st.session_state.pop(delete_key, None)
|
|
488
|
+
st.rerun()
|
|
321
489
|
|
|
322
490
|
|
|
323
491
|
def render_new_video():
|
|
@@ -335,19 +503,71 @@ def render_new_video():
|
|
|
335
503
|
else:
|
|
336
504
|
selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"], key="new_video_channel")
|
|
337
505
|
selected = [selected_one["id"]]
|
|
506
|
+
wide_style_label = st.selectbox("Estilo wide", WIDE_STYLE_OPTIONS, key="new_video_style_wide")
|
|
507
|
+
style_ia = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key="new_video_style_ia") if wide_style_label == "full_ia" else ""
|
|
508
|
+
music_path = ""
|
|
509
|
+
music_source = ""
|
|
510
|
+
if wide_style_label == "Apenas Música":
|
|
511
|
+
st.caption("Apenas Música não gera Pexels/Pixabay nem fundo IA; o áudio musical será usado como elemento principal.")
|
|
512
|
+
music_source = st.radio("Fonte da música", ["Ficheiro existente", "Carregar ficheiro", "Criar via Suno API"], horizontal=True, key="new_video_music_source")
|
|
513
|
+
if music_source == "Ficheiro existente":
|
|
514
|
+
local_music = list_music_files()
|
|
515
|
+
if local_music:
|
|
516
|
+
selected_music = st.selectbox("Música local", local_music, format_func=lambda item: item.name, key="new_video_music_existing")
|
|
517
|
+
music_path = str(selected_music)
|
|
518
|
+
else:
|
|
519
|
+
st.warning("Ainda não existem músicas em storage/music. Escolha Carregar ficheiro ou Criar via Suno API.")
|
|
520
|
+
elif music_source == "Carregar ficheiro":
|
|
521
|
+
uploaded_music = st.file_uploader("Carregar música", type=["mp3", "wav", "m4a", "aac", "flac", "ogg"], key="new_video_music_upload")
|
|
522
|
+
if uploaded_music and st.button("Guardar música local", key="new_video_music_store", use_container_width=True):
|
|
523
|
+
try:
|
|
524
|
+
stored_music = store_music_file(uploaded_music.name, uploaded_music.getvalue())
|
|
525
|
+
st.session_state["new_video_music_path"] = str(stored_music)
|
|
526
|
+
st.success(f"Música guardada em `{stored_music}`")
|
|
527
|
+
except (OSError, ValueError) as exc:
|
|
528
|
+
st.error(str(exc))
|
|
529
|
+
music_path = st.session_state.get("new_video_music_path", "")
|
|
530
|
+
else:
|
|
531
|
+
suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="new_video_suno_prompt")
|
|
532
|
+
suno_title = st.text_input("Título da música", value=topic if "topic" in locals() else "Thunderbolt music", key="new_video_suno_title")
|
|
533
|
+
if st.button("Solicitar música no Suno", key="new_video_suno_request", use_container_width=True):
|
|
534
|
+
suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
|
|
535
|
+
(st.success if suno_result["ok"] else st.error)(suno_result["message"])
|
|
536
|
+
if suno_result["ok"]:
|
|
537
|
+
try:
|
|
538
|
+
generated = materialize_suno_audio(suno_result.get("data", {}), suno_title or "suno-generated.mp3")
|
|
539
|
+
if generated:
|
|
540
|
+
st.session_state["new_video_music_path"] = str(generated)
|
|
541
|
+
st.success(f"Música descarregada para `{generated}`")
|
|
542
|
+
else:
|
|
543
|
+
st.info("O pedido foi aceite, mas o endpoint ainda não devolveu uma URL de áudio. Consulte o estado no serviço Suno e adicione o ficheiro quando estiver pronto.")
|
|
544
|
+
except (OSError, requests.RequestException, ValueError) as exc:
|
|
545
|
+
st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
|
|
546
|
+
music_path = st.session_state.get("new_video_music_path", "")
|
|
338
547
|
with st.form("new_video_form"):
|
|
339
548
|
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
340
549
|
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
341
|
-
|
|
550
|
+
legacy_language = st.session_state.get("video_language")
|
|
551
|
+
legacy_language_map = {
|
|
552
|
+
"Português": "36 – Português (Brasil)",
|
|
553
|
+
"English": "01 – Inglês",
|
|
554
|
+
"Español": "41 – Espanhol (LatAm)",
|
|
555
|
+
}
|
|
556
|
+
if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
|
|
557
|
+
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
558
|
+
language = st.selectbox("Idioma", VIDEO_LANGUAGE_OPTIONS, key="video_language")
|
|
342
559
|
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
343
|
-
style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
|
|
344
560
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
345
561
|
if submitted:
|
|
346
562
|
if not topic.strip() or not selected:
|
|
347
563
|
st.error("Informe um tópico e seleccione pelo menos um canal.")
|
|
348
564
|
else:
|
|
349
565
|
quantity = int(quantity if mode == "same_channel" else 1)
|
|
350
|
-
|
|
566
|
+
style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
|
|
567
|
+
if style == "music" and not music_path:
|
|
568
|
+
st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
|
|
569
|
+
st.stop()
|
|
570
|
+
batch = create_batch(mode, selected, topic, quantity, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source})
|
|
351
571
|
tasks = create_tasks_for_batch(batch)
|
|
352
572
|
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
|
|
353
573
|
with videos_tab:
|
|
@@ -383,12 +603,123 @@ def render_videos():
|
|
|
383
603
|
st.rerun()
|
|
384
604
|
|
|
385
605
|
|
|
606
|
+
def render_automation():
|
|
607
|
+
st.title("Automação")
|
|
608
|
+
st.caption("Agendamento diário da geração por canal. Esta versão configura e guarda a UI; não inicia gerações em segundo plano.")
|
|
609
|
+
st.info("A geração só será executada quando existir um worker/agendador ligado à pipeline. Activar Automação ON não inicia um processo automaticamente nesta etapa.")
|
|
610
|
+
channels = read_json("channels.json", [])
|
|
611
|
+
if not channels:
|
|
612
|
+
st.info("Nenhum canal cadastrado para configurar.")
|
|
613
|
+
for channel in channels:
|
|
614
|
+
channel_id = channel["id"]
|
|
615
|
+
with st.container(border=True):
|
|
616
|
+
cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
|
|
617
|
+
with cols[0]:
|
|
618
|
+
if channel.get("thumbnail_url"):
|
|
619
|
+
st.image(channel["thumbnail_url"], width=48)
|
|
620
|
+
else:
|
|
621
|
+
st.markdown("### YT")
|
|
622
|
+
with cols[1]:
|
|
623
|
+
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
624
|
+
st.caption(channel.get("handle") or channel.get("url") or "sem URL")
|
|
625
|
+
st.caption(f"Blueprint: {channel.get('default_blueprint_id') or channel.get('blueprint_id') or '—'} · Voz: {channel.get('default_voice') or channel.get('voice') or '—'}")
|
|
626
|
+
with cols[2]:
|
|
627
|
+
enabled = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
|
|
628
|
+
with cols[3]:
|
|
629
|
+
schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
|
|
630
|
+
with cols[4]:
|
|
631
|
+
if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
|
|
632
|
+
if not valid_hhmm(schedule_time):
|
|
633
|
+
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
634
|
+
else:
|
|
635
|
+
update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip()})
|
|
636
|
+
st.success("Agendamento guardado.")
|
|
637
|
+
st.rerun()
|
|
638
|
+
|
|
639
|
+
st.divider()
|
|
640
|
+
st.subheader("Vídeos cadastrados")
|
|
641
|
+
tasks = read_json("tasks.json", [])
|
|
642
|
+
if not tasks:
|
|
643
|
+
st.info("Ainda não existem vídeos cadastrados.")
|
|
644
|
+
for task in tasks:
|
|
645
|
+
with st.container(border=True):
|
|
646
|
+
task_cols = st.columns([2.6, 1.3, 1.3, 1.5])
|
|
647
|
+
with task_cols[0]:
|
|
648
|
+
st.write(f"**{task.get('topic', 'Sem tópico')}**")
|
|
649
|
+
st.caption(f"{task.get('channel_name', 'Canal')} · {task.get('id', '')}")
|
|
650
|
+
with task_cols[1]:
|
|
651
|
+
st.caption("Estado")
|
|
652
|
+
st.write(task.get("state", "—"))
|
|
653
|
+
with task_cols[2]:
|
|
654
|
+
st.caption("Estilo")
|
|
655
|
+
st.write(task.get("style_wide", "—"))
|
|
656
|
+
with task_cols[3]:
|
|
657
|
+
st.caption("Horário do canal")
|
|
658
|
+
st.write(task.get("automation_time", "00:00"))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def render_upload_direct():
|
|
662
|
+
st.subheader("Upload directo")
|
|
663
|
+
st.caption("Upload interno baseado no YouTube-Video-Upload-Frontend-Api, sem usar a quota oficial da Data API. Este método requer cookies e tokens fornecidos manualmente pelo utilizador.")
|
|
664
|
+
st.warning("Não cole cookies ou tokens em mensagens, issues ou Git. O Thunderbolt guarda-os apenas no storage local. A integração não extrai sessões automaticamente do navegador.")
|
|
665
|
+
settings = read_json("settings.json", {})
|
|
666
|
+
channels = read_json("channels.json", [])
|
|
667
|
+
tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
|
|
668
|
+
if not channels:
|
|
669
|
+
st.info("Cadastre um canal e configure o DELEGATED_SESSION_ID antes de usar o upload directo.")
|
|
670
|
+
if not tasks:
|
|
671
|
+
st.info("Não há vídeos prontos para upload directo.")
|
|
672
|
+
return
|
|
673
|
+
channel_map = {channel.get("id"): channel for channel in channels}
|
|
674
|
+
for task in tasks:
|
|
675
|
+
channel = channel_map.get(task.get("channel_id"), {})
|
|
676
|
+
video_path = (task.get("artifacts", {}) or {}).get("video", "")
|
|
677
|
+
with st.container(border=True):
|
|
678
|
+
st.write(f"**{task.get('topic', 'Sem tópico')}** — {task.get('channel_name', 'Canal')}")
|
|
679
|
+
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
680
|
+
if not channel.get("delegated_session_id"):
|
|
681
|
+
st.warning("Este canal não tem DELEGATED_SESSION_ID configurado na aba Canais.")
|
|
682
|
+
direct_cols = st.columns([2.2, 1, 1])
|
|
683
|
+
with direct_cols[0]:
|
|
684
|
+
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
685
|
+
with direct_cols[1]:
|
|
686
|
+
privacy = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"direct_privacy_{task['id']}")
|
|
687
|
+
with direct_cols[2]:
|
|
688
|
+
chunk_size = st.number_input("Chunk bytes", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)), key=f"direct_chunk_{task['id']}")
|
|
689
|
+
description = st.text_area("Descrição", value=task.get("description", ""), key=f"direct_description_{task['id']}", height=90)
|
|
690
|
+
if st.button("Enviar por Upload directo", type="primary", key=f"direct_upload_{task['id']}"):
|
|
691
|
+
result = YouTubeDirectUploader(settings, channel).upload(video_path, title=title, description=description, visibility=privacy, chunk_size=int(chunk_size))
|
|
692
|
+
record = {"task_id": task.get("id"), "channel_id": channel.get("id"), "destination": "YouTube direct frontend", "status": "published" if result.ok else "failed", "message": result.message, "data": result.data, "created_at": now()}
|
|
693
|
+
uploads = read_json("uploads.json", [])
|
|
694
|
+
uploads.append(record)
|
|
695
|
+
write_json("uploads.json", uploads)
|
|
696
|
+
(st.success if result.ok else st.error)(result.message)
|
|
697
|
+
|
|
698
|
+
|
|
386
699
|
def render_upload():
|
|
700
|
+
st.title("Upload")
|
|
701
|
+
upload_tab, direct_tab = st.tabs(["Upload convencional", "Upload directo"])
|
|
702
|
+
with direct_tab:
|
|
703
|
+
render_upload_direct()
|
|
704
|
+
with upload_tab:
|
|
705
|
+
render_upload_conventional()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def render_upload_conventional():
|
|
387
709
|
st.title("Upload")
|
|
388
710
|
settings = read_json("settings.json", {})
|
|
389
711
|
youtube = YouTubeAdapter(settings=settings)
|
|
390
712
|
tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
|
|
391
|
-
destination = st.multiselect("Destinos", ["YouTube", "TikTok"], default=["YouTube"])
|
|
713
|
+
destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"])
|
|
714
|
+
chip_colors = {"YouTube": "#ff4b4b", "TikTok": "#000000", "Instagram": "#e1306c", "Facebook Pages": "#1877f2"}
|
|
715
|
+
chips = " ".join(f'<span style="display:inline-block;background:{chip_colors[item]};color:#fff;padding:0.22rem 0.65rem;border-radius:999px;margin:0.18rem 0.25rem 0.35rem 0;font-weight:700;font-size:0.82rem">{item}</span>' for item in destination)
|
|
716
|
+
if chips:
|
|
717
|
+
st.markdown(chips, unsafe_allow_html=True)
|
|
718
|
+
|
|
719
|
+
if "Instagram" in destination:
|
|
720
|
+
st.info("Instagram está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
|
|
721
|
+
if "Facebook Pages" in destination:
|
|
722
|
+
st.info("Facebook Pages está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
|
|
392
723
|
|
|
393
724
|
if "YouTube" in destination:
|
|
394
725
|
st.markdown("**YouTube — youtube-automation-agent (principal)**")
|
|
@@ -465,6 +796,10 @@ def render_upload():
|
|
|
465
796
|
if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}"):
|
|
466
797
|
result = TikTokAdapter(settings).upload_video(video_path, task.get("topic", ""))
|
|
467
798
|
(st.success if result.ok else st.warning)(result.message)
|
|
799
|
+
if "Instagram" in destination:
|
|
800
|
+
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|
|
801
|
+
if "Facebook Pages" in destination:
|
|
802
|
+
st.button("Preparar Facebook Pages", key=f"upload_facebook_{task['id']}", disabled=True, help="UI preparada; publicação Facebook Pages ainda não está activa.")
|
|
468
803
|
|
|
469
804
|
|
|
470
805
|
def render_settings():
|
|
@@ -495,6 +830,21 @@ def render_settings():
|
|
|
495
830
|
with youtube_cols[2]:
|
|
496
831
|
youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0.")
|
|
497
832
|
|
|
833
|
+
with st.expander("Upload directo — sessão YouTube Frontend API"):
|
|
834
|
+
st.caption("Campos usados apenas pelo método Upload directo. Não são necessários para o youtube-automation-agent nem para a Data API pública.")
|
|
835
|
+
direct_cookie_cols = st.columns(3)
|
|
836
|
+
with direct_cookie_cols[0]:
|
|
837
|
+
direct_cookie_sid = text_setting("SID", "direct_cookie_sid", secret=True)
|
|
838
|
+
direct_cookie_ssid = text_setting("SSID", "direct_cookie_ssid", secret=True)
|
|
839
|
+
with direct_cookie_cols[1]:
|
|
840
|
+
direct_cookie_hsid = text_setting("HSID", "direct_cookie_hsid", secret=True)
|
|
841
|
+
direct_cookie_apisid = text_setting("APISID", "direct_cookie_apisid", secret=True)
|
|
842
|
+
with direct_cookie_cols[2]:
|
|
843
|
+
direct_cookie_sapisid = text_setting("SAPISID", "direct_cookie_sapisid", secret=True)
|
|
844
|
+
direct_session_info = text_setting("sessionInfo token", "direct_session_info", secret=True)
|
|
845
|
+
direct_innertube_api_key = text_setting("INNERTUBE_API_KEY", "direct_innertube_api_key", secret=True)
|
|
846
|
+
direct_chunk_size = st.number_input("Chunk size (múltiplo de 262144)", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)))
|
|
847
|
+
|
|
498
848
|
with st.expander("Serviço, materiais e rede"):
|
|
499
849
|
cols = st.columns(2)
|
|
500
850
|
with cols[0]:
|
|
@@ -541,7 +891,7 @@ def render_settings():
|
|
|
541
891
|
with cols[2]:
|
|
542
892
|
settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
|
|
543
893
|
|
|
544
|
-
with st.expander("Voz, TTS e música — Azure Speech
|
|
894
|
+
with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
|
|
545
895
|
cols = st.columns(2)
|
|
546
896
|
with cols[0]:
|
|
547
897
|
azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
|
|
@@ -559,6 +909,10 @@ def render_settings():
|
|
|
559
909
|
chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
|
|
560
910
|
sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
|
|
561
911
|
sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
|
|
912
|
+
st.markdown("**Suno — agente musical opcional**")
|
|
913
|
+
suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
|
|
914
|
+
suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
|
|
915
|
+
suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
|
|
562
916
|
|
|
563
917
|
with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
|
|
564
918
|
cols = st.columns(2)
|
|
@@ -592,6 +946,7 @@ def render_settings():
|
|
|
592
946
|
settings.update({
|
|
593
947
|
"port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
|
|
594
948
|
"youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
|
|
949
|
+
"direct_cookie_sid": direct_cookie_sid, "direct_cookie_ssid": direct_cookie_ssid, "direct_cookie_hsid": direct_cookie_hsid, "direct_cookie_apisid": direct_cookie_apisid, "direct_cookie_sapisid": direct_cookie_sapisid, "direct_session_info": direct_session_info, "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
|
|
595
950
|
"log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
|
|
596
951
|
"endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
|
|
597
952
|
"llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
|
|
@@ -601,7 +956,7 @@ def render_settings():
|
|
|
601
956
|
"elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
|
|
602
957
|
"pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
|
|
603
958
|
"chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
|
|
604
|
-
"sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "subtitle_provider": subtitle_provider,
|
|
959
|
+
"sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint, "subtitle_provider": subtitle_provider,
|
|
605
960
|
"ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
|
|
606
961
|
"whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
|
|
607
962
|
"tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
|
|
@@ -619,6 +974,104 @@ def render_settings():
|
|
|
619
974
|
except Exception as exc:
|
|
620
975
|
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
621
976
|
|
|
977
|
+
st.divider()
|
|
978
|
+
st.subheader("Teste de vozes")
|
|
979
|
+
st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
|
|
980
|
+
preview_cols = st.columns([1.1, 2.2, 1.2])
|
|
981
|
+
with preview_cols[0]:
|
|
982
|
+
preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
|
|
983
|
+
with preview_cols[1]:
|
|
984
|
+
if preview_provider in {"edge", "azure_speech"}:
|
|
985
|
+
preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
|
|
986
|
+
preview_voice = st.selectbox("Voz", preview_voice_options, index=preview_voice_options.index(settings.get("voice_preview_voice", "en-US-AriaNeural-Female")) if settings.get("voice_preview_voice", "en-US-AriaNeural-Female") in preview_voice_options else 0, format_func=lambda item: item or "Escolha uma voz", key="voice_preview_voice")
|
|
987
|
+
else:
|
|
988
|
+
preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
|
|
989
|
+
with preview_cols[2]:
|
|
990
|
+
preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
|
|
991
|
+
preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
|
|
992
|
+
if st.button("Testar voz", type="primary", key="voice_preview_generate"):
|
|
993
|
+
try:
|
|
994
|
+
preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
|
|
995
|
+
st.session_state["voice_preview_path"] = str(preview_path)
|
|
996
|
+
st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
|
|
997
|
+
except Exception as exc:
|
|
998
|
+
st.error(f"Não foi possível gerar o preview: {exc}")
|
|
999
|
+
preview_path = Path(st.session_state.get("voice_preview_path", ""))
|
|
1000
|
+
if preview_path.exists():
|
|
1001
|
+
st.audio(preview_path.read_bytes(), format="audio/mpeg")
|
|
1002
|
+
st.download_button("Descarregar amostra", data=preview_path.read_bytes(), file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def render_mcp():
|
|
1006
|
+
st.title("MCP")
|
|
1007
|
+
st.caption("Clientes opcionais para serviços externos. Os repositórios não são instalados nem incluídos no pacote Thunderbolt.")
|
|
1008
|
+
st.info("Activar uma integração guarda apenas a preferência local. O Thunderbolt não inicia processos externos automaticamente; a detecção verifica passivamente se existe um serviço já disponível na porta configurada.")
|
|
1009
|
+
|
|
1010
|
+
integrations = load_integrations()
|
|
1011
|
+
for integration in integrations:
|
|
1012
|
+
integration_id = integration["id"]
|
|
1013
|
+
with st.container(border=True):
|
|
1014
|
+
header_cols = st.columns([2.6, 1.15, 1.65, 1.2])
|
|
1015
|
+
with header_cols[0]:
|
|
1016
|
+
st.write(f"**{integration['name']}**")
|
|
1017
|
+
st.caption(f"{integration['protocol']} · {integration['description']}")
|
|
1018
|
+
st.markdown(f"[Abrir repositório oficial]({integration['repository']})")
|
|
1019
|
+
with header_cols[1]:
|
|
1020
|
+
port = st.number_input(
|
|
1021
|
+
"Porta",
|
|
1022
|
+
min_value=1,
|
|
1023
|
+
max_value=65535,
|
|
1024
|
+
value=int(integration.get("port", 8000)),
|
|
1025
|
+
step=1,
|
|
1026
|
+
key=f"mcp_port_{integration_id}",
|
|
1027
|
+
)
|
|
1028
|
+
with header_cols[2]:
|
|
1029
|
+
status = detect_local_service({**integration, "port": port})
|
|
1030
|
+
if status["available"]:
|
|
1031
|
+
st.success("Disponível")
|
|
1032
|
+
else:
|
|
1033
|
+
st.caption("Não detectado")
|
|
1034
|
+
st.caption(status["message"])
|
|
1035
|
+
with header_cols[3]:
|
|
1036
|
+
active = st.toggle("Activo", value=bool(integration.get("active", False)), key=f"mcp_active_{integration_id}")
|
|
1037
|
+
if active != bool(integration.get("active", False)):
|
|
1038
|
+
update_integration(integration_id, active=active)
|
|
1039
|
+
st.rerun()
|
|
1040
|
+
|
|
1041
|
+
st.caption(integration.get("endpoint_note", "Porta editável para o serviço local."))
|
|
1042
|
+
if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", use_container_width=True):
|
|
1043
|
+
update_integration(integration_id, port=int(port))
|
|
1044
|
+
st.success(f"Porta de {integration['name']} guardada: {int(port)}")
|
|
1045
|
+
st.rerun()
|
|
1046
|
+
|
|
1047
|
+
st.divider()
|
|
1048
|
+
st.subheader("Skill MoneyPrinterTurbo")
|
|
1049
|
+
st.caption("A skill anexada pode ser guardada na pasta local do Thunderbolt ou descarregada como ficheiro Markdown. Nenhum dos quatro repositórios externos é copiado para o pacote.")
|
|
1050
|
+
skill_cols = st.columns(2)
|
|
1051
|
+
with skill_cols[0]:
|
|
1052
|
+
if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
|
|
1053
|
+
try:
|
|
1054
|
+
destination = install_skill_locally()
|
|
1055
|
+
st.success(f"Skill guardada em `{destination}`")
|
|
1056
|
+
except (FileNotFoundError, OSError) as exc:
|
|
1057
|
+
st.error(f"Não foi possível guardar a skill: {exc}")
|
|
1058
|
+
with skill_cols[1]:
|
|
1059
|
+
try:
|
|
1060
|
+
skill_data = read_packaged_skill()
|
|
1061
|
+
except FileNotFoundError:
|
|
1062
|
+
skill_data = None
|
|
1063
|
+
if skill_data is not None:
|
|
1064
|
+
st.download_button(
|
|
1065
|
+
"Descarregar skill .md",
|
|
1066
|
+
data=skill_data,
|
|
1067
|
+
file_name="moneyprinterturbo-video.md",
|
|
1068
|
+
mime="text/markdown",
|
|
1069
|
+
use_container_width=True,
|
|
1070
|
+
key="mcp_download_mpt_skill",
|
|
1071
|
+
)
|
|
1072
|
+
else:
|
|
1073
|
+
st.warning("A skill ainda não está disponível nesta instalação.")
|
|
1074
|
+
|
|
622
1075
|
|
|
623
1076
|
def render_metadata_cleaner():
|
|
624
1077
|
st.title("Limpador de metadado")
|
|
@@ -755,7 +1208,9 @@ def main():
|
|
|
755
1208
|
("Blueprints", ":material/library_books:", "Blueprints"),
|
|
756
1209
|
("Canais", ":material/ondemand_video:", "Canais"),
|
|
757
1210
|
("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
|
|
1211
|
+
("Automação", ":material/schedule:", "Automação"),
|
|
758
1212
|
("Upload", ":material/cloud_upload:", "Upload"),
|
|
1213
|
+
("MCP", ":material/hub:", "MCP"),
|
|
759
1214
|
("Limpador de metadado", ":material/edit_note:", "Limpador de metadado"),
|
|
760
1215
|
("Configurações", ":material/settings:", "Configurações"),
|
|
761
1216
|
]
|
|
@@ -776,7 +1231,9 @@ def main():
|
|
|
776
1231
|
"Blueprints": render_blueprints,
|
|
777
1232
|
"Canais": render_channels,
|
|
778
1233
|
"Novo vídeo": render_new_video,
|
|
1234
|
+
"Automação": render_automation,
|
|
779
1235
|
"Upload": render_upload,
|
|
1236
|
+
"MCP": render_mcp,
|
|
780
1237
|
"Limpador de metadado": render_metadata_cleaner,
|
|
781
1238
|
"Configurações": render_settings,
|
|
782
1239
|
}
|