@danhachuel/thunderbolt 0.2.13

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 ADDED
@@ -0,0 +1,624 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import streamlit as st
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, pipeline_summary, transition_task, update_channel
14
+ from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
15
+ from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
16
+ from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
17
+ from integrations.platforms import TikTokAdapter, YouTubeAdapter
18
+ from integrations.local_runtime import MoneyPrinterRuntime
19
+ from integrations.moneyprinter_config import sync_moneyprinter_config
20
+
21
+ ensure_storage()
22
+ st.set_page_config(page_title="Thunderbolt", page_icon="T", layout="wide", initial_sidebar_state="expanded")
23
+
24
+ st.markdown("""
25
+ <style>
26
+ :root { --accent:#35a7ff; --bg:#0b1118; --card:#121b26; }
27
+ [data-testid="stAppViewContainer"] { background: radial-gradient(circle at top right, #13283b 0, #0b1118 42%); }
28
+ [data-testid="stSidebar"] { background:#091018; border-right:1px solid #1d3448; }
29
+ [data-testid="stSidebar"] [data-testid="stButton"] { margin:0.03rem 0; }
30
+ [data-testid="stSidebar"] [data-testid="stButton"] button { min-height:2.05rem; height:2.05rem; justify-content:flex-start; text-align:left; padding:0.28rem 0.7rem; border-radius:8px; border:1px solid transparent; font-weight:600; }
31
+ [data-testid="stSidebar"] [data-testid="stButton"] p { margin:0; line-height:1.1; }
32
+ [data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] { background:transparent; color:#e7edf2; }
33
+ [data-testid="stSidebar"] [data-testid="stBaseButton-secondary"]:hover { background:#1c252e; border-color:#2d3944; color:#ffffff; }
34
+ [data-testid="stSidebar"] [data-testid="stBaseButton-primary"] { background:#292929; color:#ffffff; border-color:#3a3a3a; }
35
+ [data-testid="stSidebar"] [data-testid="stBaseButton-primary"]:hover { background:#343434; color:#ffffff; }
36
+ [data-testid="stSidebar"] [data-testid="stBaseButton-primary"] span { color:#ffffff; }
37
+ [data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] span { color:#e7edf2; }
38
+ .content-card { padding: 1rem 1.1rem; border:1px solid #20384d; border-radius:14px; background:rgba(18,27,38,.92); min-height:110px; }
39
+ .content-label { color:#8ba6bb; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
40
+ .content-value { color:#f4f8fb; font-size:1.8rem; font-weight:700; margin-top:.3rem; }
41
+ .stage { border-left:3px solid #35a7ff; padding:.65rem .8rem; margin:.4rem 0; background:#101d2a; border-radius:8px; }
42
+ .small-muted { color:#8ba6bb; font-size:.85rem; }
43
+ /* Identidade visual dos destinos de upload: YouTube vermelho, TikTok preto. */
44
+ [data-testid="stMultiSelect"] [data-baseweb="tag"] { color:#ffffff !important; border:0 !important; font-weight:700 !important; }
45
+ [data-testid="stMultiSelect"] [data-baseweb="tag"] svg { color:#ffffff !important; fill:#ffffff !important; }
46
+ [data-testid="stMultiSelect"] [data-baseweb="tag"]:nth-child(1) { background:#ff4b4b !important; }
47
+ [data-testid="stMultiSelect"] [data-baseweb="tag"]:nth-child(2) { background:#000000 !important; }
48
+ </style>
49
+ """, unsafe_allow_html=True)
50
+
51
+
52
+ def card(label: str, value: str | int, note: str = ""):
53
+ st.markdown(f'<div class="content-card"><div class="content-label">{label}</div><div class="content-value">{value}</div><div class="small-muted">{note}</div></div>', unsafe_allow_html=True)
54
+
55
+
56
+ def channel_options() -> list[dict]:
57
+ return [c for c in read_json("channels.json", []) if c.get("active", True)]
58
+
59
+
60
+ def render_dashboard():
61
+ st.title("Thunderbolt")
62
+ st.caption("Interface local para operação e automação de conteúdo faceless")
63
+ summary = pipeline_summary()
64
+ cols = st.columns(6)
65
+ for col, (label, value, note) in zip(cols, [("Canais", summary["channels"], f'{summary["active_channels"]} activos'), ("Tarefas", summary["total_tasks"], "total registado"), ("A fazer", summary["pending"], "na pipeline"), ("Em execução", summary["doing"], "a decorrer"), ("Concluídos", summary["done"], "artefactos prontos"), ("Falhas", summary["failed"], "requerem atenção")]):
66
+ with col:
67
+ card(label, value, note)
68
+ st.divider()
69
+ left, right = st.columns([1.5, 1])
70
+ with left:
71
+ st.subheader("Pipeline")
72
+ tasks = read_json("tasks.json", [])
73
+ counts = {stage: sum(1 for t in tasks if t.get("stage") == stage and t.get("state") not in {"done", "cancelled"}) for stage in STAGES}
74
+ for stage in STAGES:
75
+ st.markdown(f'<div class="stage"><strong>{stage.title()}</strong> <span class="small-muted">{counts[stage]} tarefa(s)</span></div>', unsafe_allow_html=True)
76
+ with right:
77
+ st.subheader("Acções rápidas")
78
+ if st.button("Criar novo vídeo", use_container_width=True):
79
+ st.session_state["page"] = "Novo vídeo"
80
+ st.rerun()
81
+ if st.button("Importar canal", use_container_width=True):
82
+ st.session_state["page"] = "Canais"
83
+ st.rerun()
84
+ if st.button("Abrir Blueprints", use_container_width=True):
85
+ st.session_state["page"] = "Blueprints"
86
+ st.rerun()
87
+ if st.button("Abrir Upload", use_container_width=True):
88
+ st.session_state["page"] = "Upload"
89
+ st.rerun()
90
+
91
+
92
+ def render_blueprints():
93
+ st.title("Blueprints")
94
+ st.caption(f"Biblioteca local lida directamente de `{BLUEPRINTS}`")
95
+ blueprint_tab, branding_tab = st.tabs(["Blueprints", "Brandings"])
96
+ with blueprint_tab:
97
+ st.subheader("Criar blueprint a partir de link")
98
+ with st.form("create_blueprint_from_link"):
99
+ source_url = st.text_input("Link do canal ou vídeo YouTube", placeholder="https://www.youtube.com/@canal ou https://youtu.be/video")
100
+ channel_name = st.text_input("Nome do canal, se conhecido")
101
+ niche = st.text_input("Nicho alvo", placeholder="Ex.: filosofia, história, finanças pessoais")
102
+ language = st.selectbox("Idioma do blueprint", ["Português (pt-BR)", "English", "Español"])
103
+ creation_type = st.radio("O que deseja criar?", ["Apenas Blueprint", "Blueprint + Branding completo"], horizontal=True)
104
+ create_submitted = st.form_submit_button("Criar a partir do link", type="primary")
105
+ if create_submitted:
106
+ try:
107
+ blueprint, branding = create_blueprint_from_link(source_url, niche, language, creation_type == "Blueprint + Branding completo", channel_name)
108
+ blueprint_path, branding_path = save_generated_blueprint(blueprint, branding)
109
+ st.success(f"Blueprint criado: {blueprint_path.name}")
110
+ if branding_path:
111
+ st.success(f"Branding completo criado: {branding_path.name}")
112
+ st.rerun()
113
+ except ValueError as exc:
114
+ st.error(str(exc))
115
+ st.divider()
116
+ st.subheader("Importar blueprint JSON")
117
+ uploaded = st.file_uploader("Subir novo blueprint JSON", type=["json"], key="blueprint_upload")
118
+ target_folder = st.selectbox("Pasta", ["importados", "canais", "nichos"], key="blueprint_target_folder")
119
+ if uploaded and st.button("Guardar blueprint JSON", type="secondary"):
120
+ try:
121
+ data = json.loads(uploaded.getvalue().decode("utf-8"))
122
+ if not isinstance(data, dict):
123
+ raise ValueError("O JSON raiz deve ser um objecto.")
124
+ safe_name = Path(uploaded.name).stem.replace(" ", "-") + ".json"
125
+ destination = BLUEPRINTS / target_folder / safe_name
126
+ if destination.exists() and not st.checkbox("Confirmar substituição", key="confirm_blueprint_replace"):
127
+ st.warning("O ficheiro já existe. Confirme a substituição.")
128
+ else:
129
+ destination.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
130
+ st.success(f"Blueprint guardado em {destination}")
131
+ st.rerun()
132
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
133
+ st.error(f"JSON inválido: {exc}")
134
+ files = list_blueprint_files()
135
+ st.subheader(f"Blueprints existentes ({len(files)})")
136
+ search = st.text_input("Pesquisar blueprints", key="blueprint_search")
137
+ if not files:
138
+ st.info("Ainda não existem blueprints na pasta local.")
139
+ for path in files:
140
+ if search and search.lower() not in path.name.lower():
141
+ continue
142
+ try:
143
+ data = load_blueprint_file(path)
144
+ title = data.get("channel_name") or data.get("name") or data.get("title") or path.stem
145
+ with st.expander(f"{title} — {path.relative_to(BLUEPRINTS)}"):
146
+ st.caption(f"Ficheiro: {path}")
147
+ st.json(data)
148
+ except Exception as exc:
149
+ with st.expander(f"Inválido — {path.name}"):
150
+ st.error(str(exc))
151
+ with branding_tab:
152
+ st.subheader("Brandings completos")
153
+ st.caption(f"Brandings gerados ou importados da pasta `{BLUEPRINTS / 'brandings'}`")
154
+ branding_upload = st.file_uploader("Subir Branding JSON", type=["json"], key="branding_upload")
155
+ if branding_upload and st.button("Guardar Branding", type="secondary"):
156
+ try:
157
+ data = json.loads(branding_upload.getvalue().decode("utf-8"))
158
+ if not isinstance(data, dict):
159
+ raise ValueError("O JSON raiz deve ser um objecto.")
160
+ target = BLUEPRINTS / "brandings" / (Path(branding_upload.name).stem.replace(" ", "-") + ".json")
161
+ target.parent.mkdir(parents=True, exist_ok=True)
162
+ target.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
163
+ st.success(f"Branding guardado em {target}")
164
+ st.rerun()
165
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
166
+ st.error(f"Branding JSON inválido: {exc}")
167
+ branding_files = list_branding_files()
168
+ st.write(f"{len(branding_files)} branding(s) encontrado(s)")
169
+ branding_search = st.text_input("Pesquisar brandings", key="branding_search")
170
+ if not branding_files:
171
+ st.info("Ainda não existem brandings na pasta local.")
172
+ for path in branding_files:
173
+ if branding_search and branding_search.lower() not in path.name.lower():
174
+ continue
175
+ try:
176
+ data = load_blueprint_file(path)
177
+ title = data.get("name") or data.get("identity", {}).get("channel_name") or path.stem
178
+ with st.expander(f"{title} — {path.name}"):
179
+ st.caption(f"Blueprint associado: {data.get('blueprint_id') or 'não associado'}")
180
+ st.json(data)
181
+ except Exception as exc:
182
+ with st.expander(f"Inválido — {path.name}"):
183
+ st.error(str(exc))
184
+
185
+
186
+ def render_channels():
187
+ st.title("Canais")
188
+ st.caption("Importação do YouTube com edição manual e armazenamento local")
189
+ with st.expander("Criar ou importar novo canal", expanded=True):
190
+ source = st.text_input("Nome, URL, handle ou ID do canal", placeholder="https://youtube.com/@seucanal")
191
+ col1, col2 = st.columns([1, 1])
192
+ with col1:
193
+ if st.button("Buscar no YouTube", type="primary", use_container_width=True):
194
+ result = YouTubeAdapter(read_json("settings.json", {}).get("youtube_api_key", "")).fetch_channel(source)
195
+ st.session_state["yt_import"] = result.data
196
+ st.session_state["yt_message"] = result.message
197
+ st.session_state["yt_ok"] = result.ok
198
+ with col2:
199
+ if st.button("Limpar importação", use_container_width=True):
200
+ st.session_state.pop("yt_import", None)
201
+ if st.session_state.get("yt_message"):
202
+ (st.success if st.session_state.get("yt_ok") else st.warning)(st.session_state["yt_message"])
203
+ imported = st.session_state.get("yt_import", {})
204
+ with st.form("channel_form"):
205
+ name = st.text_input("Nome do canal", value=imported.get("name", ""))
206
+ url = st.text_input("URL", value=source if source.startswith("http") else imported.get("url", ""))
207
+ handle = st.text_input("Handle", value=imported.get("handle", ""))
208
+ language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0)
209
+ style = st.selectbox("Estilo wide", ["pexels", "full_ia"], index=0)
210
+ blueprint = st.text_input("Blueprint associado", value="")
211
+ submitted = st.form_submit_button("Guardar canal", type="primary")
212
+ if submitted:
213
+ if not name.strip():
214
+ st.error("Informe o nome do canal.")
215
+ else:
216
+ metadata = {"handle": handle, "language": language, "style_wide": style, "blueprint_id": blueprint, **imported}
217
+ channel = create_channel(name, url, metadata)
218
+ st.success(f"Canal {channel['name']} guardado.")
219
+ st.rerun()
220
+ st.subheader("Canais cadastrados")
221
+ channels = read_json("channels.json", [])
222
+ if not channels:
223
+ st.info("Nenhum canal cadastrado.")
224
+ return
225
+ for channel in channels:
226
+ with st.container(border=True):
227
+ cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
228
+ with cols[0]:
229
+ if channel.get("thumbnail_url"):
230
+ st.image(channel["thumbnail_url"], width=54)
231
+ else:
232
+ st.markdown("### YT")
233
+ with cols[1]:
234
+ st.write(f"**{channel.get('name', 'Sem nome')}**")
235
+ st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'}")
236
+ with cols[2]: st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
237
+ with cols[3]: st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
238
+ with cols[4]: st.metric("Backlog", channel.get("backlog_total", 0))
239
+ with cols[5]:
240
+ active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel['id']}")
241
+ if active != channel.get("active"):
242
+ update_channel(channel["id"], {"active": active})
243
+
244
+
245
+ def render_new_video():
246
+ st.title("Novo vídeo")
247
+ channels = channel_options()
248
+ if not channels:
249
+ st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
250
+ return
251
+ mode_label = st.radio("Modo de criação", ["Canal específico", "Lote no mesmo canal", "Lote geral"], horizontal=True)
252
+ mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
253
+ if mode == "general":
254
+ selected = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid))
255
+ else:
256
+ selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"])
257
+ selected = [selected_one["id"]]
258
+ with st.form("new_video_form"):
259
+ topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
260
+ quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
261
+ language = st.selectbox("Idioma", ["Português", "English", "Español"], key="video_language")
262
+ fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
263
+ style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
264
+ submitted = st.form_submit_button("Criar tarefas", type="primary")
265
+ if submitted:
266
+ if not topic.strip() or not selected:
267
+ st.error("Informe um tópico e seleccione pelo menos um canal.")
268
+ else:
269
+ quantity = int(quantity if mode == "same_channel" else 1)
270
+ batch = create_batch(mode, selected, topic, quantity, {"language": language, "format": fmt, "style_wide": style})
271
+ tasks = create_tasks_for_batch(batch)
272
+ st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s).")
273
+
274
+
275
+ def render_videos():
276
+ st.title("Vídeos e backlog")
277
+ tasks = read_json("tasks.json", [])
278
+ if not tasks:
279
+ st.info("Nenhum vídeo criado.")
280
+ return
281
+ state_filter = st.selectbox("Filtrar por estado", ["Todos", "to_do", "doing", "blocked", "done", "failed", "cancelled"])
282
+ for task in tasks:
283
+ if state_filter != "Todos" and task.get("state") != state_filter:
284
+ continue
285
+ with st.container(border=True):
286
+ cols = st.columns([2.2, 1, 1, 1.2, 1.8])
287
+ with cols[0]:
288
+ st.write(f"**{task.get('topic', 'Sem tópico')}**")
289
+ st.caption(f"{task.get('channel_name')} · {task.get('id')}")
290
+ with cols[1]: st.write(task.get("format", "wide"))
291
+ with cols[2]: st.write(task.get("stage", "—"))
292
+ with cols[3]: st.write(task.get("state", "—"))
293
+ with cols[4]:
294
+ a, b = st.columns(2)
295
+ if task.get("state") in {"to_do", "blocked", "failed"} and a.button("Iniciar", key=f"start_{task['id']}"):
296
+ transition_task(task["id"], "doing")
297
+ st.rerun()
298
+ if task.get("state") == "doing" and b.button("Parar", key=f"stop_{task['id']}"):
299
+ transition_task(task["id"], "blocked")
300
+ st.rerun()
301
+
302
+
303
+ def render_upload():
304
+ st.title("Upload")
305
+ tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
306
+ destination = st.multiselect("Destinos", ["YouTube", "TikTok"], default=["YouTube"])
307
+ if "TikTok" in destination:
308
+ status = TikTokAdapter(read_json("settings.json", {})).status()
309
+ (st.success if status.ok else st.warning)(status.message)
310
+ if not tasks:
311
+ st.info("Não há vídeos prontos para upload.")
312
+ return
313
+ for task in tasks:
314
+ with st.container(border=True):
315
+ st.write(f"**{task.get('topic')}** — {task.get('channel_name')}")
316
+ video_path = task.get("artifacts", {}).get("video", "")
317
+ st.caption(video_path or "Sem caminho de vídeo registado")
318
+ if st.button("Preparar upload", key=f"upload_{task['id']}"):
319
+ if "TikTok" in destination:
320
+ result = TikTokAdapter(read_json("settings.json", {})).upload_video(video_path, task.get("topic", ""))
321
+ (st.success if result.ok else st.warning)(result.message)
322
+ else:
323
+ st.info("Upload YouTube preparado; configure o uploader local para executar a publicação.")
324
+
325
+
326
+ def render_settings():
327
+ st.title("Configurações do Thunderbolt")
328
+ st.caption("Configuração do motor de vídeo e dos serviços usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
329
+ settings = read_json("settings.json", {})
330
+
331
+ def text_setting(label: str, key: str, *, secret: bool = False, help_text: str | None = None) -> str:
332
+ return st.text_input(
333
+ label,
334
+ settings.get(key, ""),
335
+ type="password" if secret else "default",
336
+ help=help_text,
337
+ key=f"settings_{key}",
338
+ )
339
+
340
+ with st.form("settings_form"):
341
+ st.subheader("Execução local")
342
+ port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
343
+ moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
344
+ youtube_api_key = st.text_input("YouTube Data API key", settings.get("youtube_api_key", ""), type="password")
345
+
346
+ with st.expander("Serviço, materiais e rede"):
347
+ cols = st.columns(2)
348
+ with cols[0]:
349
+ log_level = st.selectbox("Log level", ["DEBUG", "INFO", "WARNING", "ERROR"], index=["DEBUG", "INFO", "WARNING", "ERROR"].index(settings.get("log_level", "DEBUG")) if settings.get("log_level", "DEBUG") in ["DEBUG", "INFO", "WARNING", "ERROR"] else 0)
350
+ listen_host = text_setting("API listen host", "listen_host")
351
+ listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
352
+ video_source = st.selectbox("Fonte de materiais", ["pexels", "pixabay", "coverr", "loomloom", "local"], index=["pexels", "pixabay", "coverr", "loomloom", "local"].index(settings.get("video_source", "pexels")) if settings.get("video_source", "pexels") in ["pexels", "pixabay", "coverr", "loomloom", "local"] else 0)
353
+ with cols[1]:
354
+ endpoint = text_setting("Endpoint público", "endpoint")
355
+ proxy_http = text_setting("Proxy HTTP", "proxy_http")
356
+ proxy_https = text_setting("Proxy HTTPS", "proxy_https")
357
+ match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
358
+
359
+ with st.expander("LLM — providers e modelos", expanded=True):
360
+ provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
361
+ llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
362
+ llm_fields = [
363
+ ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True), ("OpenAI", "openai", True),
364
+ ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
365
+ ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
366
+ ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
367
+ ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
368
+ ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
369
+ ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
370
+ ]
371
+ for label, prefix, has_key in llm_fields:
372
+ st.markdown(f"**{label}**")
373
+ cols = st.columns(3)
374
+ with cols[0]:
375
+ if has_key:
376
+ settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
377
+ else:
378
+ settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
379
+ with cols[1]:
380
+ settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
381
+ with cols[2]:
382
+ settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
383
+
384
+ with st.expander("Voz, TTS e música"):
385
+ cols = st.columns(2)
386
+ with cols[0]:
387
+ azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
388
+ azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
389
+ siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
390
+ minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
391
+ minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
392
+ minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
393
+ minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
394
+ with cols[1]:
395
+ elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
396
+ elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
397
+ chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
398
+ chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
399
+ chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
400
+ sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
401
+ sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
402
+
403
+ with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
404
+ cols = st.columns(2)
405
+ with cols[0]:
406
+ pexels_api_keys = text_setting("Pexels API keys", "pexels_api_keys", secret=True, help_text="Separe várias chaves por vírgula para rotação.")
407
+ pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
408
+ coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
409
+ twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
410
+ material_directory = text_setting("Pasta de materiais", "material_directory")
411
+ with cols[1]:
412
+ subtitle_provider = st.selectbox("Subtitle provider", ["edge", "whisper", ""], index=["edge", "whisper", ""].index(settings.get("subtitle_provider", "edge")) if settings.get("subtitle_provider", "edge") in ["edge", "whisper", ""] else 0)
413
+ ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
414
+ video_codec = text_setting("Codec de vídeo", "video_codec")
415
+ whisper_model_size = text_setting("Whisper model", "whisper_model_size")
416
+ whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
417
+ whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
418
+
419
+ with st.expander("TikTok for Developers"):
420
+ st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
421
+ tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
422
+ tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
423
+
424
+ with st.expander("Publicação através do Upload-Post"):
425
+ upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
426
+ upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
427
+ upload_post_username = text_setting("Upload-Post username", "upload_post_username")
428
+ upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
429
+ upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
430
+
431
+ if st.form_submit_button("Guardar configurações do Thunderbolt", type="primary"):
432
+ settings.update({
433
+ "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
434
+ "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
435
+ "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
436
+ "llm_provider": llm_provider, "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
437
+ "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
438
+ "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
439
+ "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
440
+ "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
441
+ "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
442
+ "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "subtitle_provider": subtitle_provider,
443
+ "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
444
+ "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
445
+ "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
446
+ "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
447
+ "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
448
+ "upload_post_auto_upload": upload_post_auto_upload,
449
+ })
450
+ write_json("settings.json", settings)
451
+ try:
452
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
453
+ if synced:
454
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
455
+ else:
456
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
457
+ except Exception as exc:
458
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
459
+
460
+
461
+ def render_metadata_cleaner():
462
+ st.title("Limpador de metadado")
463
+ st.caption("Limpeza e edição de metadados para vídeos de terceiros que já estão prontos.")
464
+ st.warning("Esta área aceita exclusivamente vídeos externos. Vídeos criados na aba Novo vídeo não são listados nem processados aqui.")
465
+
466
+ uploaded = st.file_uploader(
467
+ "Subir vídeo de terceiro",
468
+ type=["mp4", "mov", "mkv", "webm", "avi", "m4v", "mpeg", "mpg"],
469
+ help="O sistema cria uma cópia separada em storage/metadata_cleaner/originals e nunca altera o ficheiro original enviado.",
470
+ key="metadata_external_video_upload",
471
+ )
472
+ if uploaded and st.button("Carregar vídeo externo", type="primary", key="metadata_store_external_video"):
473
+ try:
474
+ source, digest = store_external_video(uploaded.name, uploaded.getvalue())
475
+ st.session_state["metadata_external_source"] = str(source)
476
+ st.session_state["metadata_external_digest"] = digest
477
+ st.session_state["metadata_external_name"] = uploaded.name
478
+ st.success("Vídeo externo carregado numa área separada do pipeline de vídeos.")
479
+ except ValueError as exc:
480
+ st.error(str(exc))
481
+
482
+ source_value = st.session_state.get("metadata_external_source", "")
483
+ source = Path(source_value) if source_value else None
484
+ if not source or not source.exists():
485
+ st.info("Suba um vídeo de terceiro para começar. Nenhum vídeo produzido pelo sistema é usado nesta página.")
486
+ else:
487
+ st.divider()
488
+ cols = st.columns([2, 1, 1])
489
+ with cols[0]:
490
+ st.write(f"**Vídeo externo:** {st.session_state.get('metadata_external_name', source.name)}")
491
+ st.caption(f"Cópia original preservada em `{source}`")
492
+ with cols[1]:
493
+ st.metric("Tamanho", f"{source.stat().st_size / 1024 / 1024:.1f} MB")
494
+ with cols[2]:
495
+ if st.button("Trocar vídeo", key="metadata_clear_external_source"):
496
+ for key in ["metadata_external_source", "metadata_external_digest", "metadata_external_name", "metadata_last_record"]:
497
+ st.session_state.pop(key, None)
498
+ st.rerun()
499
+
500
+ st.subheader("Metadados para a versão limpa")
501
+ st.caption("A descrição segue o formato do workflow YTB Metadata Generator: preview, links e timestamps. As tags são guardadas sem hashtags.")
502
+ with st.form("metadata_cleaner_form"):
503
+ title = st.text_input("Título", value=source.stem.replace("-", " "))
504
+ preview = st.text_area("Preview / descrição curta", height=90, help="O workflow recomenda uma prévia envolvente de 100 a 200 caracteres, sem hashtags.")
505
+ links = st.text_area("Links", height=90, placeholder="Website: https://exemplo.com\nInstagram: https://instagram.com/exemplo")
506
+ timestamps = st.text_area("Timestamps / capítulos", height=120, placeholder="00:00 Introdução\n00:45 Contexto\n02:10 Conclusão")
507
+ tags = st.text_input("Tags SEO", placeholder="palavra-chave, tema do vídeo, canal dark")
508
+ left, right = st.columns(2)
509
+ with left:
510
+ language = st.text_input("Idioma", value="pt-BR")
511
+ creator = st.text_input("Criador / canal", value="")
512
+ genre = st.text_input("Género", value="")
513
+ with right:
514
+ category_options = ["Não definido", "Film & Animation", "Autos & Vehicles", "Education", "Entertainment", "Howto & Style", "People & Blogs", "Science & Technology", "News & Politics"]
515
+ category = st.selectbox("Categoria para o manifesto de upload", category_options)
516
+ copyright_text = st.text_input("Copyright", value="")
517
+ comment = st.text_input("Comentário interno", value="")
518
+ apply = st.form_submit_button("Limpar e guardar nova versão", type="primary")
519
+
520
+ if len(preview.strip()) and not 100 <= len(preview.strip()) <= 200:
521
+ st.caption(f"Prévia: {len(preview.strip())} caracteres. O workflow de referência recomenda entre 100 e 200.")
522
+ if apply:
523
+ selected_tags = normalize_tags(tags)
524
+ description = build_description(preview, links, timestamps)
525
+ metadata = {
526
+ "title": title.strip(),
527
+ "description": description,
528
+ "preview": preview.strip(),
529
+ "links": links.strip(),
530
+ "timestamps": timestamps.strip(),
531
+ "tags": selected_tags,
532
+ "language": language.strip(),
533
+ "creator": creator.strip(),
534
+ "genre": genre.strip(),
535
+ "category": "" if category == "Não definido" else category,
536
+ "copyright": copyright_text.strip(),
537
+ "comment": comment.strip(),
538
+ }
539
+ if not metadata["title"]:
540
+ st.error("Informe um título antes de limpar os metadados.")
541
+ else:
542
+ try:
543
+ output, run_info = clean_video_metadata(source, metadata, ffmpeg_path=read_json("settings.json", {}).get("ffmpeg_path", ""))
544
+ record = save_edit_record(source, output, metadata, run_info)
545
+ st.session_state["metadata_last_record"] = record
546
+ st.success("Metadados removidos e nova cópia criada. O original continua preservado.")
547
+ except (FileNotFoundError, RuntimeError, ValueError) as exc:
548
+ st.error(str(exc))
549
+
550
+ record = st.session_state.get("metadata_last_record")
551
+ if record and Path(record.get("output_path", "")).exists():
552
+ output = Path(record["output_path"])
553
+ st.subheader("Resultado")
554
+ st.write(f"**Ficheiro limpo:** `{output.name}`")
555
+ mime = "video/mp4" if output.suffix.lower() == ".mp4" else "video/*"
556
+ st.download_button("Descarregar vídeo limpo", data=output.read_bytes(), file_name=output.name, mime=mime, use_container_width=True, key="metadata_download_video")
557
+ st.download_button("Descarregar manifesto de upload (JSON)", data=metadata_manifest(record), file_name=f"{output.stem}-metadata.json", mime="application/json", use_container_width=True, key="metadata_download_manifest")
558
+ with st.expander("Pré-visualizar metadados"):
559
+ st.json(record["metadata"])
560
+
561
+ st.divider()
562
+ st.subheader("Histórico do Limpador de metadado")
563
+ records = list_edit_records()
564
+ if not records:
565
+ st.caption("Ainda não há edições registadas.")
566
+ for record in records[:10]:
567
+ output = Path(record.get("output_path", ""))
568
+ with st.container(border=True):
569
+ st.write(f"**{record.get('metadata', {}).get('title') or record.get('output_name')}**")
570
+ st.caption(f"Terceiro · {record.get('created_at', '—')} · {record.get('output_name', 'sem saída')}")
571
+ if output.exists():
572
+ st.download_button("Descarregar", data=output.read_bytes(), file_name=output.name, mime="video/*", key=f"metadata_history_{record.get('id')}")
573
+
574
+
575
+ def render_pipeline():
576
+ st.title("Pipeline")
577
+ st.caption("Estado das filas locais e dependências da cascata")
578
+ queues = read_json("queues.json", {})
579
+ blueprint_count = len(list_blueprint_files())
580
+ cols = st.columns(len(STAGES))
581
+ for col, stage in zip(cols, STAGES):
582
+ with col:
583
+ if stage == "blueprint":
584
+ card("Blueprints", blueprint_count, f"na biblioteca · {len(queues.get(stage, []))} tarefa(s) na fila")
585
+ else:
586
+ card(stage.title(), len(queues.get(stage, [])), "fila")
587
+
588
+
589
+ def main():
590
+ pages = [
591
+ ("Dashboard", ":material/home:", "Início"),
592
+ ("Pipeline", ":material/account_tree:", "Pipeline"),
593
+ ("Blueprints", ":material/library_books:", "Blueprints"),
594
+ ("Canais", ":material/ondemand_video:", "Canais"),
595
+ ("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
596
+ ("Vídeos", ":material/video_library:", "Vídeos"),
597
+ ("Upload", ":material/cloud_upload:", "Upload"),
598
+ ("Limpador de metadado", ":material/edit_note:", "Limpador de metadado"),
599
+ ("Configurações", ":material/settings:", "Configurações"),
600
+ ]
601
+ current_page = st.session_state.get("page", "Dashboard")
602
+ with st.sidebar:
603
+ st.title("Thunderbolt")
604
+ st.caption("Navegação")
605
+ for target, icon, label in pages:
606
+ if st.button(label, key=f"nav_{target}", icon=icon, use_container_width=True, type="primary" if current_page == target else "secondary"):
607
+ st.session_state["page"] = target
608
+ st.rerun()
609
+ renderers = {
610
+ "Dashboard": render_dashboard,
611
+ "Pipeline": render_pipeline,
612
+ "Blueprints": render_blueprints,
613
+ "Canais": render_channels,
614
+ "Novo vídeo": render_new_video,
615
+ "Vídeos": render_videos,
616
+ "Upload": render_upload,
617
+ "Limpador de metadado": render_metadata_cleaner,
618
+ "Configurações": render_settings,
619
+ }
620
+ renderers.get(current_page, render_dashboard)()
621
+
622
+
623
+ if __name__ == "__main__":
624
+ main()