@danhachuel/thunderbolt 0.2.47 → 0.2.49

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
@@ -36,8 +36,8 @@ from hermes_ui.music import list_music_files, materialize_suno_audio, request_su
36
36
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
37
37
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
38
38
  from integrations.youtube_direct_upload import YouTubeDirectUploader
39
- from integrations.youtube_direct_credentials import direct_account_status, document_status, parse_credentials_document, save_credentials_document
40
- from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels
39
+ from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
40
+ from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
41
41
  from integrations.local_runtime import MoneyPrinterRuntime
42
42
  from integrations.moneyprinter_config import sync_moneyprinter_config
43
43
  from integrations.openai_model_discovery import DEFAULT_NVIDIA_NIM_BASE_URL, ModelDiscoveryError, fetch_openai_compatible_models
@@ -1448,376 +1448,406 @@ def render_settings():
1448
1448
  key=f"settings_{key}",
1449
1449
  )
1450
1450
 
1451
- st.subheader("Contas Google/YouTube — canais em lote")
1452
- st.caption("Cada registo representa uma conta Google que pode gerir vários canais do YouTube. O e-mail identifica a conta; esta área não lê a caixa Gmail. Cada conta tem Client ID, Client Secret e token OAuth próprios.")
1453
- batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1454
- direct_document_uploads: dict[str, Any] = {}
1455
- direct_account_save_buttons: dict[str, bool] = {}
1456
- for batch_account in batch_accounts:
1457
- account_id = str(batch_account["id"])
1458
- with st.container(border=True):
1459
- with st.form(f"batch_account_form_{account_id}"):
1460
- account_cols = st.columns(4)
1461
- with account_cols[0]:
1462
- account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
1463
- with account_cols[1]:
1464
- account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
1465
- with account_cols[2]:
1466
- account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1467
- with account_cols[3]:
1468
- account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
1469
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
1470
- direct_status = direct_account_status(STORAGE, batch_account)
1471
- st.markdown("**Credenciais do Upload directo desta conta Google**")
1472
- st.caption("Estas credenciais pertencem apenas a este Gmail. O ficheiro contém SID, SSID, HSID, APISID e SAPISID e é guardado por ID da conta.")
1473
- direct_account_cols = st.columns([2.4, 1])
1474
- with direct_account_cols[0]:
1475
- direct_document_uploads[account_id] = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key=f"direct_credentials_document_{account_id}", help="Documento JSON com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo token, INNERTUBE_API_KEY, chunk_size e mapa delegated_session_ids.")
1476
- with direct_account_cols[1]:
1477
- if direct_status["document_exists"] and direct_status["ready"]:
1478
- st.success("Documento completo")
1479
- elif direct_status["document_exists"]:
1480
- missing_document_parts = list(direct_status["missing_cookies"])
1481
- if not direct_status["has_session_info"]:
1482
- missing_document_parts.append("sessionInfo")
1483
- if not direct_status["has_innertube_api_key"]:
1484
- missing_document_parts.append("INNERTUBE_API_KEY")
1485
- st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1486
- else:
1487
- st.warning("Sem documento de credenciais")
1488
- st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
1489
- direct_account_save_buttons[account_id] = st.button("Guardar documento de Upload directo desta conta", type="secondary", use_container_width=True, key=f"save_direct_account_{account_id}")
1490
- account_status = youtube_batch_account_status(batch_account, STORAGE)
1491
- status_cols = st.columns([2, 1, 1])
1492
- with status_cols[0]:
1493
- (st.success if account_status.ok else st.warning)(account_status.message)
1494
- with status_cols[1]:
1495
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
1496
- result = authorize_youtube_batch_account(batch_account, STORAGE)
1497
- (st.success if result.ok else st.error)(result.message)
1498
- if result.ok:
1451
+ google_accounts_tab, api_keys_tab, voice_test_tab = st.tabs(["Contas Google/YouTube — canais em lote", "API Keys", "Teste de vozes"])
1452
+
1453
+ with google_accounts_tab:
1454
+ st.subheader("Contas Google/YouTube canais em lote")
1455
+ st.caption("Cada registo representa uma conta Google que pode gerir vários canais do YouTube. O e-mail identifica a conta; esta área não lê a caixa Gmail. Cada conta tem Client ID, Client Secret e token OAuth próprios.")
1456
+ batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1457
+ direct_document_uploads: dict[str, Any] = {}
1458
+ direct_account_save_buttons: dict[str, bool] = {}
1459
+ for batch_account in batch_accounts:
1460
+ account_id = str(batch_account["id"])
1461
+ with st.container(border=True):
1462
+ with st.form(f"batch_account_form_{account_id}"):
1463
+ account_cols = st.columns(4)
1464
+ with account_cols[0]:
1465
+ account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
1466
+ with account_cols[1]:
1467
+ account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
1468
+ with account_cols[2]:
1469
+ account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1470
+ with account_cols[3]:
1471
+ account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
1472
+ account_session_info = st.text_input(
1473
+ "sessionInfo token desta conta Google",
1474
+ value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
1475
+ type="password",
1476
+ key=f"batch_session_info_{account_id}",
1477
+ help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
1478
+ )
1479
+ save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
1480
+ direct_status = direct_account_status(STORAGE, batch_account)
1481
+ st.markdown("**Credenciais do Upload directo desta conta Google**")
1482
+ st.caption("Estas credenciais pertencem apenas a este Gmail. O ficheiro contém SID, SSID, HSID, APISID e SAPISID e é guardado por ID da conta.")
1483
+ direct_account_cols = st.columns([2.4, 1])
1484
+ with direct_account_cols[0]:
1485
+ direct_document_uploads[account_id] = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key=f"direct_credentials_document_{account_id}", help="Documento JSON com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo token, INNERTUBE_API_KEY, chunk_size e mapa delegated_session_ids.")
1486
+ with direct_account_cols[1]:
1487
+ if direct_status["document_exists"] and direct_status["ready"]:
1488
+ st.success("Documento completo")
1489
+ elif direct_status["document_exists"]:
1490
+ missing_document_parts = list(direct_status["missing_cookies"])
1491
+ if not direct_status["has_session_info"]:
1492
+ missing_document_parts.append("sessionInfo")
1493
+ if not direct_status["has_innertube_api_key"]:
1494
+ missing_document_parts.append("INNERTUBE_API_KEY")
1495
+ st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1496
+ else:
1497
+ st.warning("Sem documento de credenciais")
1498
+ st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
1499
+ direct_account_save_buttons[account_id] = st.button("Guardar documento de Upload directo desta conta", type="secondary", use_container_width=True, key=f"save_direct_account_{account_id}")
1500
+ account_status = youtube_batch_account_status(batch_account, STORAGE)
1501
+ status_cols = st.columns([2, 1, 1])
1502
+ with status_cols[0]:
1503
+ (st.success if account_status.ok else st.warning)(account_status.message)
1504
+ with status_cols[1]:
1505
+ if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
1506
+ result = authorize_youtube_batch_account(batch_account, STORAGE)
1507
+ (st.success if result.ok else st.error)(result.message)
1508
+ if result.ok:
1509
+ st.rerun()
1510
+ with status_cols[2]:
1511
+ if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
1512
+ delete_youtube_batch_token(batch_account, STORAGE)
1513
+ delete_credentials_document(STORAGE, batch_account)
1514
+ remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
1515
+ settings["youtube_batch_accounts"] = remaining_accounts
1516
+ if settings.get("youtube_batch_selected_account_id") == account_id:
1517
+ settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
1518
+ channels = read_json("channels.json", [])
1519
+ channels_changed = False
1520
+ for channel in channels:
1521
+ if str(channel.get("google_account_id") or "") == account_id:
1522
+ channel.update({"google_account_id": "", "google_account_email": ""})
1523
+ channels_changed = True
1524
+ if channels_changed:
1525
+ write_json("channels.json", channels)
1526
+ write_json("settings.json", settings)
1499
1527
  st.rerun()
1500
- with status_cols[2]:
1501
- if st.button("Remover conta", key=f"batch_remove_settings_{account_id}", use_container_width=True):
1502
- delete_youtube_batch_token(batch_account, STORAGE)
1503
- remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
1504
- settings["youtube_batch_accounts"] = remaining_accounts
1505
- if settings.get("youtube_batch_selected_account_id") == account_id:
1506
- settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
1507
- write_json("settings.json", settings)
1528
+ if st.button("Repetir campos para nova conta", icon=":material/content_copy:", key=f"batch_repeat_settings_{account_id}", use_container_width=True):
1529
+ st.session_state["new_batch_account_label"] = account_label
1530
+ st.session_state["new_batch_account_email"] = account_email
1531
+ st.session_state["new_batch_account_client_id"] = account_client_id
1532
+ st.session_state["new_batch_account_client_secret"] = account_client_secret
1533
+ st.session_state["new_batch_account_session_info"] = account_session_info
1508
1534
  st.rerun()
1509
- if save_account:
1510
- if "@" not in account_email.strip():
1511
- st.error("Informe um e-mail Google válido.")
1512
- elif not account_client_id.strip() or not account_client_secret.strip():
1513
- st.error("Informe o Client ID e o Client Secret desta conta.")
1514
- else:
1515
- for existing in batch_accounts:
1516
- if str(existing.get("id")) == account_id:
1517
- credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
1518
- if credentials_changed:
1519
- delete_youtube_batch_token(existing, STORAGE)
1520
- existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip()})
1521
- settings["youtube_batch_accounts"] = batch_accounts
1522
- write_json("settings.json", settings)
1523
- st.success("Conta Google/YouTube guardada.")
1524
- st.rerun()
1525
- if direct_account_save_buttons.get(account_id):
1526
- uploaded_document = direct_document_uploads.get(account_id)
1527
- if uploaded_document is None:
1528
- st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
1529
- else:
1530
- try:
1531
- document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name)
1532
- document["account_id"] = account_id
1533
- document["email"] = str(batch_account.get("email", ""))
1534
- save_credentials_document(STORAGE, batch_account, document)
1535
- st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
1535
+ if save_account:
1536
+ if "@" not in account_email.strip():
1537
+ st.error("Informe um e-mail Google válido.")
1538
+ elif not account_client_id.strip() or not account_client_secret.strip():
1539
+ st.error("Informe o Client ID e o Client Secret desta conta.")
1540
+ else:
1541
+ for existing in batch_accounts:
1542
+ if str(existing.get("id")) == account_id:
1543
+ credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
1544
+ if credentials_changed:
1545
+ delete_youtube_batch_token(existing, STORAGE)
1546
+ existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
1547
+ update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
1548
+ settings["youtube_batch_accounts"] = batch_accounts
1549
+ write_json("settings.json", settings)
1550
+ st.success("Conta Google/YouTube guardada.")
1536
1551
  st.rerun()
1537
- except ValueError as exc:
1538
- st.error(str(exc))
1539
- with st.form("add_batch_account_form"):
1540
- st.markdown("**Credenciais do Upload directo desta conta Google**")
1541
- st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
1542
- add_cols = st.columns(4)
1543
- with add_cols[0]:
1544
- new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
1545
- with add_cols[1]:
1546
- new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
1547
- with add_cols[2]:
1548
- new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
1549
- with add_cols[3]:
1550
- new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
1551
- new_account_document = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key="new_batch_account_credentials_document", help="Documento JSON único com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo, INNERTUBE_API_KEY, chunk_size e delegated_session_ids.")
1552
- st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. Não existem inputs separados para cookies ou sessionInfo.")
1553
- add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
1554
- if add_account:
1555
- document_error = ""
1556
- if "@" not in new_account_email.strip():
1557
- st.error("Informe um e-mail Google válido.")
1558
- elif not new_account_client_id.strip() or not new_account_client_secret.strip():
1559
- st.error("Informe o Client ID e o Client Secret da nova conta.")
1560
- elif new_account_document is not None:
1561
- try:
1562
- parse_credentials_document(new_account_document.getvalue(), new_account_document.name)
1563
- except ValueError as exc:
1564
- document_error = str(exc)
1565
- st.error(document_error)
1566
- if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
1567
- new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip()}
1568
- if new_account_document is not None:
1569
- document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name)
1570
- document["account_id"] = new_account["id"]
1571
- document["email"] = new_account["email"]
1572
- save_credentials_document(STORAGE, new_account, document)
1573
- batch_accounts.append(new_account)
1574
- settings["youtube_batch_accounts"] = batch_accounts
1575
- settings["youtube_batch_selected_account_id"] = new_account["id"]
1576
- write_json("settings.json", settings)
1577
- if new_account_document is not None:
1578
- st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
1579
- else:
1580
- st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
1581
- st.rerun()
1582
-
1583
- with st.form("settings_form"):
1584
- st.subheader("Execução local")
1585
- port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
1586
- moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
1587
- st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
1588
- st.caption("Para autorizar uploads, preencha apenas o YouTube OAuth Client ID e o YouTube OAuth Client Secret. Depois, autorize o agente na aba Upload. Estes dados identificam a aplicação OAuth; não são uma Data API Key nem um token de acesso.")
1589
- youtube_cols = st.columns(2)
1590
- with youtube_cols[0]:
1591
- youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud. É usado para iniciar a autorização da conta YouTube.")
1592
- with youtube_cols[1]:
1593
- youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.")
1594
-
1595
- with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
1596
- 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.")
1597
- kaggle_cols = st.columns(3)
1598
- with kaggle_cols[0]:
1599
- kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
1600
- with kaggle_cols[1]:
1601
- 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.")
1602
- with kaggle_cols[2]:
1603
- kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
1604
-
1605
- with st.expander("Niche Finder — execução através da Apify", expanded=True):
1606
- 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.")
1607
- apify_cols = st.columns(4)
1608
- with apify_cols[0]:
1609
- 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.")
1610
- with apify_cols[1]:
1611
- apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
1612
- with apify_cols[2]:
1613
- 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)
1614
- with apify_cols[3]:
1615
- 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)
1616
-
1617
- with st.expander("Consulta oficial de métricas — opcional"):
1618
- st.caption("A YouTube Data API Key é uma credencial Google Cloud separada do OAuth. Só é necessária se escolher o método YouTube Data API para consultar métricas oficiais. Não é necessária para Página pública — sem API Key, para autorizar OAuth ou para fazer upload.")
1619
- youtube_api_key = text_setting("YouTube Data API Key (opcional)", "youtube_api_key", secret=True, help_text="Credencial separada, criada em Google Cloud > APIs e serviços > Credenciais > Chave de API. Não cole aqui o Client ID nem o Client Secret.")
1620
-
1621
- st.caption("As credenciais e parâmetros do Upload directo — cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e DELEGATED_SESSION_ID — são lidos exclusivamente do documento JSON por conta Google. Não são editados nesta UI.")
1622
- direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
1623
- direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
1624
-
1625
- with st.expander("Serviço, materiais e rede"):
1626
- cols = st.columns(2)
1627
- with cols[0]:
1628
- 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)
1629
- listen_host = text_setting("API listen host", "listen_host")
1630
- listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
1631
- 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)
1632
- with cols[1]:
1633
- endpoint = text_setting("Endpoint público", "endpoint")
1634
- proxy_http = text_setting("Proxy HTTP", "proxy_http")
1635
- proxy_https = text_setting("Proxy HTTPS", "proxy_https")
1636
- match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
1637
-
1638
- with st.expander("LLM — providers e modelos", expanded=True):
1639
- provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
1640
- 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)
1641
- st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
1642
- 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.")
1643
- openai_cols = st.columns(3)
1644
- with openai_cols[0]:
1645
- 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.")
1646
- with openai_cols[1]:
1647
- 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")
1648
- with openai_cols[2]:
1649
- cached_catalog = st.session_state.get("openai_model_catalog", {})
1650
- catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
1651
- cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
1652
- current_model_name = str(settings.get("openai_model_name", "") or "")
1653
- manual_option = "__manual_model__"
1654
- if cached_models:
1655
- model_options = [manual_option, *cached_models]
1656
- model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
1657
- 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")
1658
- if selected_model == manual_option:
1659
- 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")
1552
+ if direct_account_save_buttons.get(account_id):
1553
+ uploaded_document = direct_document_uploads.get(account_id)
1554
+ if uploaded_document is None:
1555
+ st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
1660
1556
  else:
1661
- openai_model_name = selected_model
1557
+ try:
1558
+ document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name, session_info_override=account_session_info.strip())
1559
+ document["account_id"] = account_id
1560
+ document["email"] = str(batch_account.get("email", ""))
1561
+ save_credentials_document(STORAGE, batch_account, document)
1562
+ st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
1563
+ st.rerun()
1564
+ except ValueError as exc:
1565
+ st.error(str(exc))
1566
+ with st.form("add_batch_account_form"):
1567
+ st.markdown("**Credenciais do Upload directo desta conta Google**")
1568
+ st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
1569
+ add_cols = st.columns(4)
1570
+ with add_cols[0]:
1571
+ new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
1572
+ with add_cols[1]:
1573
+ new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
1574
+ with add_cols[2]:
1575
+ new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
1576
+ with add_cols[3]:
1577
+ new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
1578
+ new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies, INNERTUBE_API_KEY, chunk_size e delegated_session_ids continuam apenas no documento JSON.")
1579
+ new_account_document = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key="new_batch_account_credentials_document", help="Documento JSON único com cookies SID/SSID/HSID/APISID/SAPISID, INNERTUBE_API_KEY, chunk_size e delegated_session_ids. O sessionInfo pode ser preenchido no campo acima ou no documento.")
1580
+ st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. O campo sessionInfo é específico desta conta; os cookies e restantes credenciais continuam apenas no documento.")
1581
+ add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
1582
+ if add_account:
1583
+ document_error = ""
1584
+ if "@" not in new_account_email.strip():
1585
+ st.error("Informe um e-mail Google válido.")
1586
+ elif not new_account_client_id.strip() or not new_account_client_secret.strip():
1587
+ st.error("Informe o Client ID e o Client Secret da nova conta.")
1588
+ elif new_account_document is not None:
1589
+ try:
1590
+ parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
1591
+ except ValueError as exc:
1592
+ document_error = str(exc)
1593
+ st.error(document_error)
1594
+ if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
1595
+ new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip(), "sessionInfo": new_account_session_info.strip()}
1596
+ if new_account_document is not None:
1597
+ document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
1598
+ document["account_id"] = new_account["id"]
1599
+ document["email"] = new_account["email"]
1600
+ save_credentials_document(STORAGE, new_account, document)
1601
+ batch_accounts.append(new_account)
1602
+ settings["youtube_batch_accounts"] = batch_accounts
1603
+ settings["youtube_batch_selected_account_id"] = new_account["id"]
1604
+ write_json("settings.json", settings)
1605
+ if new_account_document is not None:
1606
+ st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
1662
1607
  else:
1663
- 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")
1664
- refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
1665
- if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
1666
- st.warning(str(cached_catalog["error"]))
1667
- elif cached_models:
1668
- st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
1669
- else:
1670
- st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
1671
- llm_fields = [
1672
- ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
1673
- ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
1674
- ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
1675
- ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
1676
- ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
1677
- ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
1678
- ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
1679
- ]
1680
- for label, prefix, has_key in llm_fields:
1681
- st.markdown(f"**{label}**")
1682
- cols = st.columns(3)
1608
+ st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
1609
+ st.rerun()
1610
+
1611
+ with api_keys_tab:
1612
+ with st.form("settings_form"):
1613
+ st.subheader("API Keys")
1614
+ port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
1615
+ moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
1616
+ st.markdown("**YouTube OAuth 2.0 e consulta pública**")
1617
+ st.caption("Para autorizar uploads, preencha apenas o YouTube OAuth Client ID e o YouTube OAuth Client Secret. Depois, autorize o agente na aba Upload. Estes dados identificam a aplicação OAuth; não são uma Data API Key nem um token de acesso.")
1618
+ st.info(f"OAuth local: use um cliente do tipo Desktop app. Se o Google Cloud pedir uma URI autorizada, registe exactamente `{loopback_redirect_uri()}`.")
1619
+ youtube_cols = st.columns(2)
1620
+ with youtube_cols[0]:
1621
+ youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud. É usado para iniciar a autorização da conta YouTube.")
1622
+ with youtube_cols[1]:
1623
+ youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.")
1624
+
1625
+ with st.expander("Niche Finder execução remota no Kaggle", expanded=True):
1626
+ 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.")
1627
+ kaggle_cols = st.columns(3)
1628
+ with kaggle_cols[0]:
1629
+ kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
1630
+ with kaggle_cols[1]:
1631
+ 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.")
1632
+ with kaggle_cols[2]:
1633
+ kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
1634
+
1635
+ with st.expander("Niche Finder — execução através da Apify", expanded=True):
1636
+ 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.")
1637
+ apify_cols = st.columns(4)
1638
+ with apify_cols[0]:
1639
+ 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.")
1640
+ with apify_cols[1]:
1641
+ apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
1642
+ with apify_cols[2]:
1643
+ 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)
1644
+ with apify_cols[3]:
1645
+ 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)
1646
+
1647
+ with st.expander("Consulta oficial de métricas — opcional"):
1648
+ st.caption("A YouTube Data API Key é uma credencial Google Cloud separada do OAuth. Só é necessária se escolher o método YouTube Data API para consultar métricas oficiais. Não é necessária para Página pública — sem API Key, para autorizar OAuth ou para fazer upload.")
1649
+ youtube_api_key = text_setting("YouTube Data API Key (opcional)", "youtube_api_key", secret=True, help_text="Credencial separada, criada em Google Cloud > APIs e serviços > Credenciais > Chave de API. Não cole aqui o Client ID nem o Client Secret.")
1650
+
1651
+ st.caption("As credenciais e parâmetros do Upload directo — cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e DELEGATED_SESSION_ID — são lidos exclusivamente do documento JSON por conta Google. Não são editados nesta UI.")
1652
+ direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
1653
+ direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
1654
+
1655
+ with st.expander("Serviço, materiais e rede"):
1656
+ cols = st.columns(2)
1683
1657
  with cols[0]:
1684
- if has_key:
1685
- settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
1658
+ 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)
1659
+ listen_host = text_setting("API listen host", "listen_host")
1660
+ listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
1661
+ 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)
1662
+ with cols[1]:
1663
+ endpoint = text_setting("Endpoint público", "endpoint")
1664
+ proxy_http = text_setting("Proxy HTTP", "proxy_http")
1665
+ proxy_https = text_setting("Proxy HTTPS", "proxy_https")
1666
+ match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
1667
+
1668
+ with st.expander("LLM — providers e modelos", expanded=True):
1669
+ provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
1670
+ 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)
1671
+ st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
1672
+ 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.")
1673
+ openai_cols = st.columns(3)
1674
+ with openai_cols[0]:
1675
+ 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.")
1676
+ with openai_cols[1]:
1677
+ 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")
1678
+ with openai_cols[2]:
1679
+ cached_catalog = st.session_state.get("openai_model_catalog", {})
1680
+ catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
1681
+ cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
1682
+ current_model_name = str(settings.get("openai_model_name", "") or "")
1683
+ manual_option = "__manual_model__"
1684
+ if cached_models:
1685
+ model_options = [manual_option, *cached_models]
1686
+ model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
1687
+ 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")
1688
+ if selected_model == manual_option:
1689
+ 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")
1690
+ else:
1691
+ openai_model_name = selected_model
1686
1692
  else:
1687
- settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
1693
+ 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")
1694
+ refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
1695
+ if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
1696
+ st.warning(str(cached_catalog["error"]))
1697
+ elif cached_models:
1698
+ st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
1699
+ else:
1700
+ st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
1701
+ llm_fields = [
1702
+ ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
1703
+ ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
1704
+ ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
1705
+ ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
1706
+ ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
1707
+ ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
1708
+ ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
1709
+ ]
1710
+ for label, prefix, has_key in llm_fields:
1711
+ st.markdown(f"**{label}**")
1712
+ cols = st.columns(3)
1713
+ with cols[0]:
1714
+ if has_key:
1715
+ settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
1716
+ else:
1717
+ settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
1718
+ with cols[1]:
1719
+ settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
1720
+ with cols[2]:
1721
+ settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
1722
+
1723
+ with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
1724
+ cols = st.columns(2)
1725
+ with cols[0]:
1726
+ azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
1727
+ azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
1728
+ siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
1729
+ minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
1730
+ minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
1731
+ minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
1732
+ minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
1688
1733
  with cols[1]:
1689
- settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
1690
- with cols[2]:
1691
- settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
1692
-
1693
- with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
1694
- cols = st.columns(2)
1695
- with cols[0]:
1696
- azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
1697
- azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
1698
- siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
1699
- minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
1700
- minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
1701
- minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
1702
- minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
1703
- with cols[1]:
1704
- elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
1705
- elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
1706
- chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
1707
- chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
1708
- chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
1709
- sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
1710
- sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
1711
- st.markdown("**Suno agente musical opcional**")
1712
- suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
1713
- 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.")
1714
- suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
1715
-
1716
- with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
1717
- cols = st.columns(2)
1718
- with cols[0]:
1719
- 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.")
1720
- pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
1721
- coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
1722
- twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
1723
- material_directory = text_setting("Pasta de materiais", "material_directory")
1724
- with cols[1]:
1725
- 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)
1726
- ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
1727
- video_codec = text_setting("Codec de vídeo", "video_codec")
1728
- whisper_model_size = text_setting("Whisper model", "whisper_model_size")
1729
- whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
1730
- whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
1731
-
1732
- with st.expander("TikTok for Developers Client ID e Client Secret", expanded=True):
1733
- 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.")
1734
- tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
1735
- tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
1736
-
1737
- with st.expander("Publicação através do Upload-Post"):
1738
- upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
1739
- upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
1740
- upload_post_username = text_setting("Upload-Post username", "upload_post_username")
1741
- upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
1742
- upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
1743
-
1744
- if refresh_openai_models:
1745
- try:
1746
- discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
1747
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
1748
- st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
1749
- st.rerun()
1750
- except ModelDiscoveryError as exc:
1751
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
1752
- st.rerun()
1734
+ elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
1735
+ elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
1736
+ chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
1737
+ chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
1738
+ chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
1739
+ sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
1740
+ sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
1741
+ st.markdown("**Suno agente musical opcional**")
1742
+ suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
1743
+ 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.")
1744
+ suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
1745
+
1746
+ with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
1747
+ cols = st.columns(2)
1748
+ with cols[0]:
1749
+ 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.")
1750
+ pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
1751
+ coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
1752
+ twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
1753
+ material_directory = text_setting("Pasta de materiais", "material_directory")
1754
+ with cols[1]:
1755
+ 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)
1756
+ ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
1757
+ video_codec = text_setting("Codec de vídeo", "video_codec")
1758
+ whisper_model_size = text_setting("Whisper model", "whisper_model_size")
1759
+ whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
1760
+ whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
1761
+
1762
+ with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
1763
+ 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.")
1764
+ tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
1765
+ tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
1766
+
1767
+ with st.expander("Publicação através do Upload-Post"):
1768
+ upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
1769
+ upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
1770
+ upload_post_username = text_setting("Upload-Post username", "upload_post_username")
1771
+ upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
1772
+ upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
1773
+
1774
+ if refresh_openai_models:
1775
+ try:
1776
+ discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
1777
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
1778
+ st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
1779
+ st.rerun()
1780
+ except ModelDiscoveryError as exc:
1781
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
1782
+ st.rerun()
1753
1783
 
1754
- save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
1755
- if save_all_settings:
1756
- settings.update({
1757
- "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1758
- "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
1759
- "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
1760
- "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),
1761
- "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1762
- "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
1763
- "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
1764
- "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
1765
- "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
1766
- "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
1767
- "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
1768
- "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
1769
- "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
1770
- "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
1771
- "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,
1772
- "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
1773
- "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
1774
- "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
1775
- "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
1776
- "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
1777
- "upload_post_auto_upload": upload_post_auto_upload,
1778
- })
1779
- write_json("settings.json", settings)
1784
+ save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
1785
+ if save_all_settings:
1786
+ settings.update({
1787
+ "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1788
+ "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
1789
+ "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
1790
+ "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),
1791
+ "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1792
+ "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
1793
+ "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
1794
+ "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
1795
+ "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
1796
+ "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
1797
+ "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
1798
+ "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
1799
+ "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
1800
+ "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
1801
+ "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,
1802
+ "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
1803
+ "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
1804
+ "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
1805
+ "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
1806
+ "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
1807
+ "upload_post_auto_upload": upload_post_auto_upload,
1808
+ })
1809
+ write_json("settings.json", settings)
1810
+ try:
1811
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
1812
+ if synced:
1813
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
1814
+ else:
1815
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1816
+ except Exception as exc:
1817
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1818
+
1819
+ with voice_test_tab:
1820
+ st.subheader("Teste de vozes")
1821
+ st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
1822
+ preview_cols = st.columns([1.1, 2.2, 1.2])
1823
+ with preview_cols[0]:
1824
+ preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
1825
+ with preview_cols[1]:
1826
+ if preview_provider in {"edge", "azure_speech"}:
1827
+ preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
1828
+ 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")
1829
+ else:
1830
+ preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
1831
+ with preview_cols[2]:
1832
+ preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
1833
+ preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
1834
+ if st.button("Testar voz", type="primary", key="voice_preview_generate"):
1835
+ st.session_state.pop("voice_preview_path", None)
1780
1836
  try:
1781
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
1782
- if synced:
1783
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
1784
- else:
1785
- st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1837
+ preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
1838
+ st.session_state["voice_preview_path"] = str(preview_path)
1839
+ st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
1786
1840
  except Exception as exc:
1787
- st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1788
-
1789
- st.divider()
1790
- st.subheader("Teste de vozes")
1791
- st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
1792
- preview_cols = st.columns([1.1, 2.2, 1.2])
1793
- with preview_cols[0]:
1794
- preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
1795
- with preview_cols[1]:
1796
- if preview_provider in {"edge", "azure_speech"}:
1797
- preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
1798
- 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")
1799
- else:
1800
- preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
1801
- with preview_cols[2]:
1802
- preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
1803
- preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
1804
- if st.button("Testar voz", type="primary", key="voice_preview_generate"):
1805
- st.session_state.pop("voice_preview_path", None)
1806
- try:
1807
- preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
1808
- st.session_state["voice_preview_path"] = str(preview_path)
1809
- st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
1810
- except Exception as exc:
1811
- st.error(f"Não foi possível gerar o preview: {exc}")
1812
- preview_value = str(st.session_state.get("voice_preview_path", "") or "").strip()
1813
- loaded_preview = load_preview_file(preview_value)
1814
- if loaded_preview:
1815
- preview_path, preview_data = loaded_preview
1816
- st.audio(preview_data, format="audio/mpeg")
1817
- st.download_button("Descarregar amostra", data=preview_data, file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
1818
- elif preview_value:
1819
- st.session_state.pop("voice_preview_path", None)
1820
- st.warning("A amostra de voz anterior não é um ficheiro de áudio legível e foi removida do estado local. Teste a voz novamente.")
1841
+ st.error(f"Não foi possível gerar o preview: {exc}")
1842
+ preview_value = str(st.session_state.get("voice_preview_path", "") or "").strip()
1843
+ loaded_preview = load_preview_file(preview_value)
1844
+ if loaded_preview:
1845
+ preview_path, preview_data = loaded_preview
1846
+ st.audio(preview_data, format="audio/mpeg")
1847
+ st.download_button("Descarregar amostra", data=preview_data, file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
1848
+ elif preview_value:
1849
+ st.session_state.pop("voice_preview_path", None)
1850
+ st.warning("A amostra de voz anterior não é um ficheiro de áudio legível e foi removida do estado local. Teste a voz novamente.")
1821
1851
 
1822
1852
 
1823
1853
  def render_mcp():