@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/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"] p { margin:0; line-height:1; }
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", ["pexels", "full_ia"], index=0, key="yt_import_style")
234
- blueprint = st.text_input("Blueprint associado", value="", key="yt_import_blueprint")
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", ["pexels", "full_ia"], index=0, key="manual_channel_style")
272
- blueprint = st.text_input("Blueprint associado", key="manual_channel_blueprint")
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
- language = st.selectbox("Idioma", ["Português", "English", "Español"], key="video_language")
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
- batch = create_batch(mode, selected, topic, quantity, {"language": language, "format": fmt, "style_wide": style})
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 e restantes serviços", expanded=True):
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
  }