@danhachuel/thunderbolt 0.2.74 → 0.2.76

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
@@ -22,7 +22,7 @@ except (OSError, json.JSONDecodeError):
22
22
 
23
23
  from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
24
24
  from hermes_ui.automation_worker import load_worker_status
25
- from hermes_ui.storage import BLUEPRINTS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
25
+ from hermes_ui.storage import BLUEPRINTS, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
26
26
  from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
27
27
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
28
28
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
@@ -33,7 +33,9 @@ from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEd
33
33
  from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
34
34
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
35
35
  from hermes_ui.mcp_server import server_status, start_server, stop_server
36
+ from hermes_ui.material_sources import material_api_keys, material_source_catalog, selected_material_source, update_material_api_keys
36
37
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
38
+ from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
37
39
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
38
40
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
39
41
  from hermes_ui.script_generation import generate_script_document
@@ -2110,6 +2112,120 @@ def render_edit_placeholder(page_title: str, description: str):
2110
2112
  st.info("Esta aba está reservada para desenvolvimento futuro e ainda não executa nenhuma operação.")
2111
2113
 
2112
2114
 
2115
+ def render_media_download():
2116
+ st.title("Download Mídia")
2117
+ st.caption("Baixe vídeos e áudio de URLs públicas através da API oficial do yt-dlp. Use esta ferramenta apenas com conteúdo que tem autorização para descarregar e utilizar.")
2118
+ st.markdown("Baseado em [yt-dlp](https://github.com/yt-dlp/yt-dlp), um downloader open source para vídeo e áudio.")
2119
+ dependency = dependency_status()
2120
+ if not dependency["yt_dlp"]:
2121
+ st.warning("yt-dlp não está instalado neste ambiente. Execute a instalação das dependências do Thunderbolt antes de iniciar um download.")
2122
+ st.info("A combinação de streams, conversão de áudio e incorporação de metadados pode exigir FFmpeg. Downloads longos permanecem nesta página até terminarem.")
2123
+
2124
+ with st.form("media_download_form"):
2125
+ urls_text = st.text_area("URLs para descarregar", placeholder="Uma URL http(s) por linha", height=120, key="media_download_urls")
2126
+ mode_label = st.radio("Tipo de mídia", ["Vídeo", "Áudio"], horizontal=True, key="media_download_mode")
2127
+ option_cols = st.columns(3)
2128
+ with option_cols[0]:
2129
+ if mode_label == "Vídeo":
2130
+ quality_label = st.selectbox("Qualidade", list(VIDEO_QUALITY_OPTIONS), key="media_download_quality")
2131
+ video_container = st.selectbox("Contentor", list(VIDEO_CONTAINERS), key="media_download_container")
2132
+ audio_format = "mp3"
2133
+ else:
2134
+ quality_label = "Melhor qualidade"
2135
+ video_container = "mp4"
2136
+ audio_format = st.selectbox("Formato de áudio", list(AUDIO_FORMATS), key="media_download_audio_format")
2137
+ with option_cols[1]:
2138
+ allow_playlist = st.checkbox("Permitir playlist", value=False, key="media_download_allow_playlist")
2139
+ download_subtitles = st.checkbox("Descarregar legendas", value=False, key="media_download_subtitles")
2140
+ with option_cols[2]:
2141
+ embed_metadata = st.checkbox("Incorporar metadados", value=False, key="media_download_embed_metadata")
2142
+ st.caption("Playlist desactivada por padrão para evitar downloads acidentais em massa.")
2143
+ start_download = st.form_submit_button("Iniciar download", type="primary", use_container_width=True)
2144
+
2145
+ if start_download:
2146
+ progress = st.progress(0, text="A preparar o download…")
2147
+ progress_status = st.empty()
2148
+
2149
+ def on_progress(payload: dict[str, Any]) -> None:
2150
+ value = float(payload.get("progress") or 0)
2151
+ progress.progress(int(max(0, min(100, value))), text=f"{payload.get('status', 'processing').capitalize()} · {payload.get('current_file') or payload.get('display_url') or 'a processar'}")
2152
+ progress_status.caption(str(payload.get("hook_status") or payload.get("status") or "processing"))
2153
+
2154
+ try:
2155
+ results = download_media(
2156
+ urls_text,
2157
+ mode="video" if mode_label == "Vídeo" else "audio",
2158
+ quality=quality_label,
2159
+ container=video_container,
2160
+ audio_format=audio_format,
2161
+ allow_playlist=allow_playlist,
2162
+ download_subtitles=download_subtitles,
2163
+ embed_metadata=embed_metadata,
2164
+ progress_callback=on_progress,
2165
+ )
2166
+ st.session_state["media_download_last_results"] = results
2167
+ completed = sum(1 for item in results if item.get("status") == "completed")
2168
+ failed = len(results) - completed
2169
+ if completed:
2170
+ st.success(f"{completed} download(s) concluído(s) e guardado(s) em `{MEDIA_DOWNLOADS}`.")
2171
+ if failed:
2172
+ st.warning(f"{failed} download(s) terminou/terminaram com erro. Consulte o histórico abaixo.")
2173
+ except (MediaDownloadError, ValueError, OSError) as exc:
2174
+ progress.empty()
2175
+ st.error(str(exc))
2176
+
2177
+ latest_results = st.session_state.get("media_download_last_results", [])
2178
+ if latest_results:
2179
+ st.subheader("Resultado da última execução")
2180
+ for record in latest_results:
2181
+ with st.container(border=True):
2182
+ status_label = "Concluído" if record.get("status") == "completed" else "Falhou"
2183
+ st.write(f"**{record.get('title') or record.get('display_url') or 'Download'}** — {status_label}")
2184
+ st.caption(f"{record.get('display_url', 'URL não disponível')} · {record.get('mode', 'video')} · {record.get('completed_at') or record.get('created_at', '—')}")
2185
+ if record.get("error"):
2186
+ st.error(record["error"])
2187
+ for filename in record.get("files", []):
2188
+ output = media_download_file(record, str(filename))
2189
+ if output:
2190
+ st.download_button("Descarregar ficheiro", data=output.read_bytes(), file_name=output.name, mime="audio/*" if record.get("mode") == "audio" else "video/*", key=f"media_result_{record.get('operation_id')}_{filename}")
2191
+
2192
+ st.divider()
2193
+ st.subheader("Histórico de downloads")
2194
+ history = list_media_downloads()
2195
+ action_cols = st.columns([1, 1, 3])
2196
+ with action_cols[0]:
2197
+ if st.button("Actualizar histórico", key="media_download_refresh"):
2198
+ st.rerun()
2199
+ with action_cols[1]:
2200
+ clear_requested = st.button("Limpar histórico", key="media_download_clear")
2201
+ if clear_requested:
2202
+ st.session_state["media_download_confirm_clear"] = True
2203
+ if st.session_state.get("media_download_confirm_clear"):
2204
+ st.warning("Isto remove apenas o histórico, não os ficheiros guardados em storage/downloads.")
2205
+ confirm_cols = st.columns(2)
2206
+ with confirm_cols[0]:
2207
+ if st.button("Confirmar limpeza", type="primary", key="media_download_confirm_clear_button"):
2208
+ clear_media_download_history()
2209
+ st.session_state.pop("media_download_confirm_clear", None)
2210
+ st.rerun()
2211
+ with confirm_cols[1]:
2212
+ if st.button("Cancelar", key="media_download_cancel_clear_button"):
2213
+ st.session_state.pop("media_download_confirm_clear", None)
2214
+ st.rerun()
2215
+ if not history:
2216
+ st.caption("Ainda não existem downloads registados.")
2217
+ for record in history:
2218
+ status_label = {"completed": "Concluído", "failed": "Falhou", "processing": "Em processamento"}.get(str(record.get("status")), str(record.get("status") or "—"))
2219
+ with st.expander(f"{record.get('title') or record.get('display_url') or 'Download'} — {status_label}", expanded=False):
2220
+ st.caption(f"{record.get('display_url', 'URL não disponível')} · {record.get('mode', 'video')} · {record.get('created_at', '—')}")
2221
+ if record.get("error"):
2222
+ st.error(record["error"])
2223
+ for filename in record.get("files", []):
2224
+ output = media_download_file(record, str(filename))
2225
+ if output:
2226
+ st.download_button("Descarregar", data=output.read_bytes(), file_name=output.name, mime="audio/*" if record.get("mode") == "audio" else "video/*", key=f"media_history_{record.get('operation_id')}_{filename}")
2227
+
2228
+
2113
2229
  def render_cuts():
2114
2230
  st.title("Cortes")
2115
2231
  st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, com um fluxo local inspirado no Clip Generator do OpenShorts.")
@@ -3123,6 +3239,51 @@ def render_google_accounts():
3123
3239
  st.success("Configuração global do YouTube guardada em Contas Google.")
3124
3240
  st.rerun()
3125
3241
 
3242
+ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3243
+ st.subheader("Fontes de materiais")
3244
+ st.caption("Seleccione a fonte que será usada na pipeline e guarde uma ou mais API keys. Qualidade, endpoints e parâmetros internos são definidos pelo Thunderbolt.")
3245
+ source_catalog = material_source_catalog()
3246
+ source_codes = [item["code"] for item in source_catalog] + ["local"]
3247
+ source_labels = {item["code"]: item["label"] for item in source_catalog} | {"local": "Ficheiros locais"}
3248
+ source_help = {item["code"]: item["description"] for item in source_catalog} | {"local": "Usar materiais já existentes no storage local; não requer API key."}
3249
+ selected_source = st.selectbox(
3250
+ "Fonte de materiais",
3251
+ source_codes,
3252
+ index=source_codes.index(selected_material_source(settings)) if selected_material_source(settings) in source_codes else 0,
3253
+ format_func=lambda value: source_labels.get(value, value),
3254
+ key="material_source_selector",
3255
+ )
3256
+ st.caption(source_help.get(selected_source, ""))
3257
+ current_keys = material_api_keys(settings, selected_source)
3258
+ row_key = f"material_source_key_rows_{selected_source}"
3259
+ row_count = max(len(current_keys), int(st.session_state.get(row_key, len(current_keys) or 1)))
3260
+ if selected_source == "local":
3261
+ st.info("A fonte local não usa API key. Os materiais devem existir na pasta configurada do storage.")
3262
+ if st.button("Guardar fonte local", type="primary", key="save_local_material_source"):
3263
+ settings["video_source"] = selected_source
3264
+ write_json("settings.json", settings)
3265
+ st.success("Fonte de materiais guardada: ficheiros locais.")
3266
+ st.rerun()
3267
+ return
3268
+ with st.form(f"material_api_keys_form_{selected_source}"):
3269
+ key_values: list[str] = []
3270
+ for index in range(row_count):
3271
+ key_values.append(st.text_input(f"API Key {index + 1}", value=current_keys[index] if index < len(current_keys) else "", type="password", key=f"material_api_key_{selected_source}_{index}"))
3272
+ save_keys = st.form_submit_button("Guardar fonte e chaves", type="primary", use_container_width=True)
3273
+ add_key = st.form_submit_button("Adicionar outra chave", use_container_width=True)
3274
+ if add_key:
3275
+ st.session_state[row_key] = row_count + 1
3276
+ st.rerun()
3277
+ if save_keys:
3278
+ update_material_api_keys(settings, selected_source, key_values)
3279
+ settings["video_source"] = selected_source
3280
+ write_json("settings.json", settings)
3281
+ st.session_state[row_key] = max(1, len(material_api_keys(settings, selected_source)))
3282
+ st.success(f"Fonte {source_labels.get(selected_source, selected_source)} guardada com {len(material_api_keys(settings, selected_source))} chave(s).")
3283
+ st.rerun()
3284
+ st.caption(f"{len(current_keys)} chave(s) actualmente guardada(s) para {source_labels.get(selected_source, selected_source)}. As chaves são mantidas no storage local e usadas com rotação interna.")
3285
+
3286
+
3126
3287
  def render_settings():
3127
3288
  st.title("Configuração API")
3128
3289
  st.caption("Configuração das APIs, providers, serviços e ferramentas técnicas usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
@@ -3140,221 +3301,191 @@ def render_settings():
3140
3301
  api_keys_tab, voice_test_tab = st.tabs(["API Keys", "Teste de vozes"])
3141
3302
 
3142
3303
  with api_keys_tab:
3143
- with st.form("settings_form"):
3144
- st.subheader("API Keys")
3145
- port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
3146
- moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
3147
- with st.expander("Niche Finder execução remota no Kaggle", expanded=True):
3148
- st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
3149
- kaggle_cols = st.columns(3)
3150
- with kaggle_cols[0]:
3151
- kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
3152
- with kaggle_cols[1]:
3153
- kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
3154
- with kaggle_cols[2]:
3155
- kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
3156
-
3157
- with st.expander("Niche Finder execução através da Apify", expanded=True):
3158
- st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
3159
- apify_cols = st.columns(4)
3160
- with apify_cols[0]:
3161
- apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
3162
- with apify_cols[1]:
3163
- apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
3164
- with apify_cols[2]:
3165
- apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
3166
- with apify_cols[3]:
3167
- apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
3168
-
3169
- with st.expander("Serviço, materiais e rede"):
3170
- cols = st.columns(2)
3171
- with cols[0]:
3172
- 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)
3173
- listen_host = text_setting("API listen host", "listen_host")
3174
- listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
3175
- 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)
3176
- with cols[1]:
3177
- endpoint = text_setting("Endpoint público", "endpoint")
3178
- proxy_http = text_setting("Proxy HTTP", "proxy_http")
3179
- proxy_https = text_setting("Proxy HTTPS", "proxy_https")
3180
- match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
3181
-
3182
- with st.expander("Nano Banana geração de thumbnails", expanded=True):
3183
- st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
3184
- nano_cols = st.columns(2)
3185
- with nano_cols[0]:
3186
- gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
3187
- gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
3188
- with nano_cols[1]:
3189
- gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
3190
- gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
3191
-
3192
- with st.expander("LLM — providers e modelos", expanded=True):
3193
- provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
3194
- 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)
3195
- st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
3196
- st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
3197
- openai_cols = st.columns(3)
3198
- with openai_cols[0]:
3199
- openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
3200
- with openai_cols[1]:
3201
- openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
3202
- with openai_cols[2]:
3203
- cached_catalog = st.session_state.get("openai_model_catalog", {})
3204
- catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
3205
- cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
3206
- current_model_name = str(settings.get("openai_model_name", "") or "")
3207
- manual_option = "__manual_model__"
3208
- if cached_models:
3209
- model_options = [manual_option, *cached_models]
3210
- model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
3211
- selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
3212
- if selected_model == manual_option:
3213
- openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
3304
+ api_service_tab, material_sources_tab = st.tabs(["Serviços e modelos", "Fontes de materiais"])
3305
+ with api_service_tab:
3306
+ with st.form("settings_form"):
3307
+ st.subheader("API Keys")
3308
+ port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
3309
+ moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
3310
+ with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
3311
+ st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
3312
+ kaggle_cols = st.columns(3)
3313
+ with kaggle_cols[0]:
3314
+ kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
3315
+ with kaggle_cols[1]:
3316
+ kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
3317
+ with kaggle_cols[2]:
3318
+ kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
3319
+
3320
+ with st.expander("Niche Finder — execução através da Apify", expanded=True):
3321
+ st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
3322
+ apify_cols = st.columns(4)
3323
+ with apify_cols[0]:
3324
+ apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
3325
+ with apify_cols[1]:
3326
+ apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
3327
+ with apify_cols[2]:
3328
+ apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
3329
+ with apify_cols[3]:
3330
+ apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
3331
+
3332
+ with st.expander("Nano Banana — geração de thumbnails", expanded=True):
3333
+ st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
3334
+ nano_cols = st.columns(2)
3335
+ with nano_cols[0]:
3336
+ gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
3337
+ gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
3338
+ with nano_cols[1]:
3339
+ gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
3340
+ gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
3341
+
3342
+ with st.expander("LLM — providers e modelos", expanded=True):
3343
+ provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
3344
+ 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)
3345
+ st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
3346
+ st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
3347
+ openai_cols = st.columns(3)
3348
+ with openai_cols[0]:
3349
+ openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
3350
+ with openai_cols[1]:
3351
+ openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
3352
+ with openai_cols[2]:
3353
+ cached_catalog = st.session_state.get("openai_model_catalog", {})
3354
+ catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
3355
+ cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
3356
+ current_model_name = str(settings.get("openai_model_name", "") or "")
3357
+ manual_option = "__manual_model__"
3358
+ if cached_models:
3359
+ model_options = [manual_option, *cached_models]
3360
+ model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
3361
+ selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
3362
+ if selected_model == manual_option:
3363
+ openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
3364
+ else:
3365
+ openai_model_name = selected_model
3214
3366
  else:
3215
- openai_model_name = selected_model
3367
+ openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
3368
+ refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
3369
+ if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
3370
+ st.warning(str(cached_catalog["error"]))
3371
+ elif cached_models:
3372
+ st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
3216
3373
  else:
3217
- openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
3218
- refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
3219
- if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
3220
- st.warning(str(cached_catalog["error"]))
3221
- elif cached_models:
3222
- st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
3223
- else:
3224
- st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
3225
- llm_fields = [
3226
- ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
3227
- ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
3228
- ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
3229
- ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
3230
- ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
3231
- ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
3232
- ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
3233
- ]
3234
- for label, prefix, has_key in llm_fields:
3235
- st.markdown(f"**{label}**")
3236
- cols = st.columns(3)
3374
+ st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
3375
+ llm_fields = [
3376
+ ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
3377
+ ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
3378
+ ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
3379
+ ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
3380
+ ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
3381
+ ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
3382
+ ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
3383
+ ]
3384
+ for label, prefix, has_key in llm_fields:
3385
+ st.markdown(f"**{label}**")
3386
+ cols = st.columns(3)
3387
+ with cols[0]:
3388
+ if has_key:
3389
+ settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
3390
+ else:
3391
+ settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
3392
+ with cols[1]:
3393
+ settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
3394
+ with cols[2]:
3395
+ settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
3396
+
3397
+ with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
3398
+ cols = st.columns(2)
3237
3399
  with cols[0]:
3238
- if has_key:
3239
- settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
3240
- else:
3241
- settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
3400
+ azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
3401
+ azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
3402
+ siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
3403
+ minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
3404
+ minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
3405
+ minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
3406
+ minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
3242
3407
  with cols[1]:
3243
- settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
3244
- with cols[2]:
3245
- settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
3246
-
3247
- with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
3248
- cols = st.columns(2)
3249
- with cols[0]:
3250
- azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
3251
- azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
3252
- siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
3253
- minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
3254
- minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
3255
- minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
3256
- minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
3257
- with cols[1]:
3258
- elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
3259
- elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
3260
- chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
3261
- chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
3262
- chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
3263
- sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
3264
- sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
3265
- st.markdown("**Suno agente musical opcional**")
3266
- suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
3267
- 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.")
3268
- suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
3269
-
3270
- with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
3271
- cols = st.columns(2)
3272
- with cols[0]:
3273
- 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.")
3274
- pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
3275
- coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
3276
- twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
3277
- material_directory = text_setting("Pasta de materiais", "material_directory")
3278
- with cols[1]:
3279
- 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)
3280
- ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
3281
- video_codec = text_setting("Codec de vídeo", "video_codec")
3282
- whisper_model_size = text_setting("Whisper model", "whisper_model_size")
3283
- whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
3284
- whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
3285
-
3286
- with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
3287
- 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.")
3288
- tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
3289
- tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
3290
-
3291
- with st.expander("Publicação através do Upload-Post"):
3292
- upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
3293
- upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
3294
- upload_post_username = text_setting("Upload-Post username", "upload_post_username")
3295
- upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
3296
- upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
3297
-
3298
- with st.expander("Postiz — API key, integração e MCP", expanded=True):
3299
- st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
3300
- postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
3301
- postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
3302
- postiz_cols = st.columns(2)
3303
- with postiz_cols[0]:
3304
- postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
3305
- postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
3306
- postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
3307
- with postiz_cols[1]:
3308
- postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
3309
- postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
3310
- st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
3311
-
3312
- if refresh_openai_models:
3313
- try:
3314
- discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
3315
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
3316
- st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
3317
- st.rerun()
3318
- except ModelDiscoveryError as exc:
3319
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
3320
- st.rerun()
3408
+ elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
3409
+ elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
3410
+ chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
3411
+ chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
3412
+ chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
3413
+ sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
3414
+ sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
3415
+ st.markdown("**Suno agente musical opcional**")
3416
+ suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
3417
+ 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.")
3418
+ suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
3419
+
3420
+ with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
3421
+ 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.")
3422
+ tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
3423
+ tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
3424
+
3425
+ with st.expander("Publicação através do Upload-Post"):
3426
+ upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
3427
+ upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
3428
+ upload_post_username = text_setting("Upload-Post username", "upload_post_username")
3429
+ upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
3430
+ upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
3431
+
3432
+ with st.expander("Postiz API key, integração e MCP", expanded=True):
3433
+ st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
3434
+ postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
3435
+ postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
3436
+ postiz_cols = st.columns(2)
3437
+ with postiz_cols[0]:
3438
+ postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
3439
+ postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
3440
+ postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
3441
+ with postiz_cols[1]:
3442
+ postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
3443
+ postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
3444
+ st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz é tentado depois da API Oficial e do Upload directo.")
3445
+
3446
+ if refresh_openai_models:
3447
+ try:
3448
+ discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
3449
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
3450
+ st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
3451
+ st.rerun()
3452
+ except ModelDiscoveryError as exc:
3453
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
3454
+ st.rerun()
3321
3455
 
3322
- save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3323
- if save_all_settings:
3324
- settings.update({
3325
- "port": port, "moneyprinter_path": moneyprinter_path,
3326
- "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3327
- "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3328
- "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
3329
- "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
3330
- "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3331
- "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3332
- "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3333
- "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3334
- "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
3335
- "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
3336
- "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
3337
- "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
3338
- "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,
3339
- "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
3340
- "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
3341
- "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
3342
- "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
3343
- "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
3344
- "upload_post_auto_upload": upload_post_auto_upload,
3345
- "postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
3346
- "postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
3347
- "postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
3348
- })
3349
- write_json("settings.json", settings)
3350
- try:
3351
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
3352
- if synced:
3353
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
3354
- else:
3355
- st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
3356
- except Exception as exc:
3357
- st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
3456
+ save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3457
+ if save_all_settings:
3458
+ settings.update({
3459
+ "port": port, "moneyprinter_path": moneyprinter_path,
3460
+ "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3461
+ "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3462
+ "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3463
+ "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3464
+ "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3465
+ "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3466
+ "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
3467
+ "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
3468
+ "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
3469
+ "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,
3470
+ "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
3471
+ "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
3472
+ "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
3473
+ "upload_post_auto_upload": upload_post_auto_upload,
3474
+ "postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
3475
+ "postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
3476
+ "postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
3477
+ })
3478
+ write_json("settings.json", settings)
3479
+ try:
3480
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
3481
+ if synced:
3482
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
3483
+ else:
3484
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
3485
+ except Exception as exc:
3486
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
3487
+ with material_sources_tab:
3488
+ render_material_source_api_keys(settings)
3358
3489
 
3359
3490
  with voice_test_tab:
3360
3491
  st.subheader("Teste de vozes")
@@ -3776,6 +3907,7 @@ def main():
3776
3907
  ("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
3777
3908
  ("Cortes", ":material/content_cut:", "Cortes"),
3778
3909
  ("Editor Python", ":material/code:", "Editor Python"),
3910
+ ("Download Mídia", ":material/download:", "Download Mídia"),
3779
3911
  ]
3780
3912
  models_ai_items = [
3781
3913
  ("Personagens", ":material/person:", "Personagens"),
@@ -3882,6 +4014,7 @@ def main():
3882
4014
  "Limpador de Metadados": render_metadata_cleaner,
3883
4015
  "Cortes": render_cuts,
3884
4016
  "Editor Python": render_python_editor,
4017
+ "Download Mídia": render_media_download,
3885
4018
  "AI Influencers": lambda: render_edit_placeholder("AI Influencers", "Seleccione uma das abas AI Influencers no menu expansível."),
3886
4019
  "Tutorial Meta": render_models_ai_tutorial,
3887
4020
  "Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),