@danhachuel/thunderbolt 0.2.66 → 0.2.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +3 -3
- package/README.md +5 -3
- package/app/main.py +441 -219
- package/hermes_ui/storage.py +1 -0
- package/integrations/tiktok_public.py +229 -0
- package/package.json +1 -1
- package/seed/references/guide-instagram.md +179 -0
package/app/main.py
CHANGED
|
@@ -40,6 +40,7 @@ from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesiz
|
|
|
40
40
|
from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
41
41
|
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
42
42
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
43
|
+
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
43
44
|
from integrations.postiz import PostizAdapter
|
|
44
45
|
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
45
46
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
@@ -754,6 +755,159 @@ def render_blueprints():
|
|
|
754
755
|
st.error(str(exc))
|
|
755
756
|
|
|
756
757
|
|
|
758
|
+
def _tiktok_accounts_from_settings(settings: dict[str, Any]) -> list[dict[str, Any]]:
|
|
759
|
+
raw_accounts = settings.get("tiktok_accounts")
|
|
760
|
+
if not isinstance(raw_accounts, list) or not raw_accounts:
|
|
761
|
+
raw_accounts = settings.get("tiktok_profiles", [])
|
|
762
|
+
if not isinstance(raw_accounts, list):
|
|
763
|
+
return []
|
|
764
|
+
accounts: list[dict[str, Any]] = []
|
|
765
|
+
seen: set[str] = set()
|
|
766
|
+
for raw in raw_accounts:
|
|
767
|
+
if not isinstance(raw, dict):
|
|
768
|
+
continue
|
|
769
|
+
try:
|
|
770
|
+
reference = normalize_tiktok_reference(str(raw.get("url") or raw.get("handle") or raw.get("username") or raw.get("id") or ""))
|
|
771
|
+
except ValueError:
|
|
772
|
+
continue
|
|
773
|
+
account = {**raw, **reference}
|
|
774
|
+
account["name"] = str(raw.get("name") or raw.get("label") or reference["username"]).strip()
|
|
775
|
+
account["bio"] = str(raw.get("bio") or raw.get("description") or "").strip()
|
|
776
|
+
account["source"] = str(raw.get("source") or raw.get("origin") or "manual").strip()
|
|
777
|
+
key = account["id"]
|
|
778
|
+
if key in seen:
|
|
779
|
+
continue
|
|
780
|
+
seen.add(key)
|
|
781
|
+
accounts.append(account)
|
|
782
|
+
return accounts
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _save_tiktok_accounts(accounts: list[dict[str, Any]]) -> None:
|
|
786
|
+
settings = read_json("settings.json", {})
|
|
787
|
+
settings["tiktok_accounts"] = accounts
|
|
788
|
+
write_json("settings.json", settings)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _upsert_tiktok_account(account: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
|
792
|
+
settings = read_json("settings.json", {})
|
|
793
|
+
accounts = _tiktok_accounts_from_settings(settings)
|
|
794
|
+
normalized = normalize_tiktok_reference(str(account.get("url") or account.get("handle") or account.get("username") or ""))
|
|
795
|
+
merged = {**account, **normalized}
|
|
796
|
+
merged["name"] = str(account.get("name") or normalized["username"]).strip() or normalized["username"]
|
|
797
|
+
existing_index = next((index for index, item in enumerate(accounts) if item.get("id") == normalized["id"] or item.get("username") == normalized["username"]), None)
|
|
798
|
+
created = existing_index is None
|
|
799
|
+
if existing_index is None:
|
|
800
|
+
accounts.append(merged)
|
|
801
|
+
else:
|
|
802
|
+
accounts[existing_index] = {**accounts[existing_index], **merged}
|
|
803
|
+
_save_tiktok_accounts(accounts)
|
|
804
|
+
return accounts, created
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _tiktok_account_metric(account: dict[str, Any], key: str, label: str) -> str:
|
|
808
|
+
value = account.get(key)
|
|
809
|
+
return f"{label}: {value:,}" if isinstance(value, int) else f"{label}: —"
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def render_tiktok_accounts():
|
|
813
|
+
st.title("Contas TikTok")
|
|
814
|
+
st.caption("Pesquisa pública e cadastro manual de contas TikTok para alimentar o selector de destino no Upload. Esta área não usa OAuth, API de publicação nem lote.")
|
|
815
|
+
settings = read_json("settings.json", {})
|
|
816
|
+
accounts = _tiktok_accounts_from_settings(settings)
|
|
817
|
+
|
|
818
|
+
search_tab, manual_tab, library_tab = st.tabs(["Pesquisa pública", "Cadastro manual", "Contas cadastradas"])
|
|
819
|
+
with search_tab:
|
|
820
|
+
st.subheader("Pesquisar perfil público")
|
|
821
|
+
st.info("A pesquisa consulta apenas a página pública do perfil e pode devolver dados incompletos. Se o TikTok bloquear a consulta, utilize o cadastro manual.")
|
|
822
|
+
with st.form("tiktok_public_lookup_form"):
|
|
823
|
+
lookup_source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta ou @conta", key="tiktok_public_lookup_source")
|
|
824
|
+
lookup_submitted = st.form_submit_button("Pesquisar perfil público", type="primary", use_container_width=True)
|
|
825
|
+
if lookup_submitted:
|
|
826
|
+
result = fetch_public_tiktok_profile(lookup_source)
|
|
827
|
+
st.session_state["tiktok_public_lookup"] = {"ok": result.ok, "message": result.message, "data": result.data}
|
|
828
|
+
(st.success if result.ok else st.warning)(result.message)
|
|
829
|
+
lookup = st.session_state.get("tiktok_public_lookup", {})
|
|
830
|
+
lookup_data = lookup.get("data") if isinstance(lookup, dict) else None
|
|
831
|
+
if isinstance(lookup_data, dict) and lookup_data.get("url"):
|
|
832
|
+
with st.container(border=True):
|
|
833
|
+
st.subheader(str(lookup_data.get("name") or lookup_data.get("handle") or "Perfil TikTok"))
|
|
834
|
+
st.caption(f"{lookup_data.get('handle', '')} · {lookup_data.get('public_url') or lookup_data.get('url')}")
|
|
835
|
+
if lookup_data.get("bio"):
|
|
836
|
+
st.write(lookup_data["bio"])
|
|
837
|
+
metric_cols = st.columns(4)
|
|
838
|
+
with metric_cols[0]: st.metric("Seguidores", lookup_data.get("subscriber_count") if lookup_data.get("subscriber_count") is not None else "—")
|
|
839
|
+
with metric_cols[1]: st.metric("Seguindo", lookup_data.get("following_count") if lookup_data.get("following_count") is not None else "—")
|
|
840
|
+
with metric_cols[2]: st.metric("Gostos", lookup_data.get("likes_count") if lookup_data.get("likes_count") is not None else "—")
|
|
841
|
+
with metric_cols[3]: st.metric("Vídeos", lookup_data.get("video_count") if lookup_data.get("video_count") is not None else "—")
|
|
842
|
+
display_name = st.text_input("Nome da conta", value=str(lookup_data.get("name") or lookup_data.get("username") or ""), key="tiktok_lookup_display_name")
|
|
843
|
+
notes = st.text_area("Observações internas", value=str(lookup_data.get("notes") or ""), key="tiktok_lookup_notes", height=80)
|
|
844
|
+
if st.button("Cadastrar conta TikTok", type="primary", use_container_width=True, key="tiktok_register_public_account"):
|
|
845
|
+
try:
|
|
846
|
+
stored = {**lookup_data, "name": display_name.strip() or lookup_data.get("username", ""), "notes": notes.strip(), "source": "public_lookup"}
|
|
847
|
+
_upsert_tiktok_account(stored)
|
|
848
|
+
st.session_state.pop("tiktok_public_lookup", None)
|
|
849
|
+
st.success("Conta TikTok cadastrada e disponível no selector de Upload.")
|
|
850
|
+
st.rerun()
|
|
851
|
+
except ValueError as exc:
|
|
852
|
+
st.error(str(exc))
|
|
853
|
+
|
|
854
|
+
with manual_tab:
|
|
855
|
+
st.subheader("Cadastrar conta manualmente")
|
|
856
|
+
with st.form("tiktok_manual_account_form"):
|
|
857
|
+
manual_source = st.text_input("@handle ou URL pública", placeholder="@minhaconta", key="tiktok_manual_source")
|
|
858
|
+
manual_name = st.text_input("Nome da conta", placeholder="Nome de apresentação", key="tiktok_manual_name")
|
|
859
|
+
manual_notes = st.text_area("Observações internas", height=90, key="tiktok_manual_notes")
|
|
860
|
+
manual_submitted = st.form_submit_button("Guardar cadastro manual", type="primary", use_container_width=True)
|
|
861
|
+
if manual_submitted:
|
|
862
|
+
try:
|
|
863
|
+
reference = normalize_tiktok_reference(manual_source)
|
|
864
|
+
_upsert_tiktok_account({**reference, "name": manual_name.strip() or reference["username"], "notes": manual_notes.strip(), "source": "manual"})
|
|
865
|
+
st.success("Conta TikTok cadastrada manualmente.")
|
|
866
|
+
st.rerun()
|
|
867
|
+
except ValueError as exc:
|
|
868
|
+
st.error(str(exc))
|
|
869
|
+
|
|
870
|
+
with library_tab:
|
|
871
|
+
st.subheader(f"Contas TikTok cadastradas ({len(accounts)})")
|
|
872
|
+
if not accounts:
|
|
873
|
+
st.info("Ainda não existem contas TikTok. Use Pesquisa pública ou Cadastro manual para adicionar a primeira conta.")
|
|
874
|
+
for account in accounts:
|
|
875
|
+
account_id = str(account.get("id") or account.get("username"))
|
|
876
|
+
with st.container(border=True):
|
|
877
|
+
card_cols = st.columns([0.18, 2.7, 1.1, 1.1, 0.8])
|
|
878
|
+
with card_cols[0]:
|
|
879
|
+
if account.get("avatar_url"):
|
|
880
|
+
st.image(account["avatar_url"], width=52)
|
|
881
|
+
else:
|
|
882
|
+
st.markdown("### TT")
|
|
883
|
+
with card_cols[1]:
|
|
884
|
+
st.write(f"**{account.get('name') or account.get('username')}**")
|
|
885
|
+
st.caption(f"{account.get('handle') or '@' + str(account.get('username', ''))} · {account.get('public_url') or account.get('url')}")
|
|
886
|
+
st.caption(str(account.get("source") or "manual").replace("_", " ").title())
|
|
887
|
+
with card_cols[2]: st.caption(_tiktok_account_metric(account, "subscriber_count", "Seguidores"))
|
|
888
|
+
with card_cols[3]: st.caption(_tiktok_account_metric(account, "video_count", "Vídeos"))
|
|
889
|
+
with card_cols[4]:
|
|
890
|
+
if st.button("Apagar", key=f"delete_tiktok_account_{account_id}"):
|
|
891
|
+
_save_tiktok_accounts([item for item in accounts if str(item.get("id")) != account_id])
|
|
892
|
+
st.success("Conta TikTok removida.")
|
|
893
|
+
st.rerun()
|
|
894
|
+
with st.expander("Editar conta", expanded=False):
|
|
895
|
+
with st.form(f"edit_tiktok_account_{account_id}"):
|
|
896
|
+
edited_name = st.text_input("Nome da conta", value=str(account.get("name") or account.get("username") or ""), key=f"edit_tiktok_name_{account_id}")
|
|
897
|
+
edited_source = st.text_input("@handle ou URL pública", value=str(account.get("public_url") or account.get("url") or account.get("handle") or ""), key=f"edit_tiktok_source_{account_id}")
|
|
898
|
+
edited_notes = st.text_area("Observações internas", value=str(account.get("notes") or ""), key=f"edit_tiktok_notes_{account_id}", height=80)
|
|
899
|
+
edit_submitted = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
|
|
900
|
+
if edit_submitted:
|
|
901
|
+
try:
|
|
902
|
+
reference = normalize_tiktok_reference(edited_source)
|
|
903
|
+
updated = {**account, **reference, "name": edited_name.strip() or reference["username"], "notes": edited_notes.strip(), "source": account.get("source") or "manual"}
|
|
904
|
+
_upsert_tiktok_account(updated)
|
|
905
|
+
st.success("Conta TikTok actualizada.")
|
|
906
|
+
st.rerun()
|
|
907
|
+
except ValueError as exc:
|
|
908
|
+
st.error(str(exc))
|
|
909
|
+
|
|
910
|
+
|
|
757
911
|
def render_tiktok_prompt_masters():
|
|
758
912
|
st.title("Prompts Master")
|
|
759
913
|
st.caption(f"Biblioteca exclusiva para vídeos TikTok. Os ficheiros ficam em `{TIKTOK_PROMPT_MASTERS}` e nunca entram na pasta de Blueprints YouTube.")
|
|
@@ -856,7 +1010,7 @@ def render_channels():
|
|
|
856
1010
|
if lookup_mode.startswith("Página pública"):
|
|
857
1011
|
result = youtube.fetch_channel_public(source)
|
|
858
1012
|
elif not youtube.api_key:
|
|
859
|
-
result = IntegrationResult(False, "A YouTube Data API Key não está configurada. Escolha a opção Página pública — sem API Key ou configure a chave em Configurações.", {"status": "api_key_not_configured"})
|
|
1013
|
+
result = IntegrationResult(False, "A YouTube Data API Key não está configurada. Escolha a opção Página pública — sem API Key ou configure a chave em Configurações > Contas Google.", {"status": "api_key_not_configured"})
|
|
860
1014
|
else:
|
|
861
1015
|
result = youtube.fetch_channel(source)
|
|
862
1016
|
st.session_state["yt_import"] = result.data
|
|
@@ -938,7 +1092,7 @@ def render_channels():
|
|
|
938
1092
|
st.caption("Esta subaba usa a conta Google/YouTube seleccionada para listar os canais que ela gere. Não lê a caixa Gmail e não usa e-mails como pesquisa pública.")
|
|
939
1093
|
accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
940
1094
|
if not accounts:
|
|
941
|
-
st.info("Configure primeiro uma conta em Configurações >
|
|
1095
|
+
st.info("Configure primeiro uma conta em Configurações > Configuração API > Contas Google/YouTube para canais em lote.")
|
|
942
1096
|
else:
|
|
943
1097
|
account_ids = [str(account["id"]) for account in accounts]
|
|
944
1098
|
account_labels = {str(account["id"]): f"{account.get('email', 'Conta Google')} — {account.get('label', 'Canais YouTube')}" for account in accounts}
|
|
@@ -1391,7 +1545,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1391
1545
|
payload["thumbnail_path"] = thumbnail_path
|
|
1392
1546
|
payload["thumbnail_status"] = "generated"
|
|
1393
1547
|
else:
|
|
1394
|
-
st.caption("A imagem ainda não foi gerada. Configure a API key em
|
|
1548
|
+
st.caption("A imagem ainda não foi gerada. Configure a API key em Configuração API > API Keys.")
|
|
1395
1549
|
st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
|
|
1396
1550
|
else:
|
|
1397
1551
|
topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
|
|
@@ -1445,7 +1599,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1445
1599
|
payload["thumbnail_path"] = thumbnail_path
|
|
1446
1600
|
payload["thumbnail_status"] = "generated"
|
|
1447
1601
|
else:
|
|
1448
|
-
st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em
|
|
1602
|
+
st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configuração API > API Keys.")
|
|
1449
1603
|
st.session_state["new_video_creative_payload"] = payload
|
|
1450
1604
|
|
|
1451
1605
|
with st.form("new_video_form"):
|
|
@@ -2434,11 +2588,11 @@ def render_upload():
|
|
|
2434
2588
|
|
|
2435
2589
|
def render_upload_postiz():
|
|
2436
2590
|
st.subheader("Upload para Postiz")
|
|
2437
|
-
st.caption("O Thunderbolt envia primeiro o MP4 para o Postiz e cria um post na integração seleccionada. A API key e o servidor são configurados em
|
|
2591
|
+
st.caption("O Thunderbolt envia primeiro o MP4 para o Postiz e cria um post na integração seleccionada. A API key e o servidor são configurados em Configuração API.")
|
|
2438
2592
|
settings = read_json("settings.json", {})
|
|
2439
2593
|
postiz = PostizAdapter(settings)
|
|
2440
2594
|
if not settings.get("postiz_enabled"):
|
|
2441
|
-
st.warning("Postiz está desactivado. Active-o em
|
|
2595
|
+
st.warning("Postiz está desactivado. Active-o em Configuração API > API Keys e guarde a API key antes de enviar.")
|
|
2442
2596
|
if not postiz.api_key:
|
|
2443
2597
|
st.info("Nenhuma API key Postiz configurada.")
|
|
2444
2598
|
return
|
|
@@ -2465,7 +2619,7 @@ def render_upload_postiz():
|
|
|
2465
2619
|
format_func=lambda value: integration_labels.get(value, value),
|
|
2466
2620
|
key="postiz_upload_integration",
|
|
2467
2621
|
)
|
|
2468
|
-
st.caption("Para alterar a integração padrão, guarde o ID seleccionado em
|
|
2622
|
+
st.caption("Para alterar a integração padrão, guarde o ID seleccionado em Configuração API.")
|
|
2469
2623
|
else:
|
|
2470
2624
|
selected_integration = st.text_input("ID da integração Postiz", value=integration_default, key="postiz_upload_integration_manual", help="Carregue as integrações para seleccionar uma conta ou introduza o ID devolvido pela API do Postiz.").strip()
|
|
2471
2625
|
st.info("Carregue as integrações para descobrir os canais Postiz ligados à sua conta.")
|
|
@@ -2509,7 +2663,7 @@ def render_upload_postiz():
|
|
|
2509
2663
|
|
|
2510
2664
|
|
|
2511
2665
|
UPLOAD_DESTINATION_TARGET_KEYS = {
|
|
2512
|
-
"TikTok": "
|
|
2666
|
+
"TikTok": "tiktok_accounts",
|
|
2513
2667
|
"Instagram": "instagram_profiles",
|
|
2514
2668
|
"Facebook Pages": "facebook_pages",
|
|
2515
2669
|
}
|
|
@@ -2539,6 +2693,8 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
|
|
|
2539
2693
|
if not setting_key:
|
|
2540
2694
|
return []
|
|
2541
2695
|
configured_targets = settings.get(setting_key, [])
|
|
2696
|
+
if destination == "TikTok" and (not isinstance(configured_targets, list) or not configured_targets):
|
|
2697
|
+
configured_targets = settings.get("tiktok_profiles", [])
|
|
2542
2698
|
if not isinstance(configured_targets, list):
|
|
2543
2699
|
return []
|
|
2544
2700
|
targets: list[Any] = []
|
|
@@ -2553,12 +2709,14 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
|
|
|
2553
2709
|
def render_upload_destination_target(destination: str, channels: list[dict[str, Any]], settings: dict[str, Any]) -> Any | None:
|
|
2554
2710
|
options = upload_targets_for_destination(destination, channels, settings)
|
|
2555
2711
|
destination_key = re.sub(r"[^a-z0-9]+", "_", destination.lower()).strip("_")
|
|
2556
|
-
select_label = "Canal" if destination == "YouTube" else "Perfil / página"
|
|
2557
|
-
empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else f"Nenhum {destination} configurado"
|
|
2712
|
+
select_label = "Canal" if destination == "YouTube" else ("Conta TikTok" if destination == "TikTok" else "Perfil / página")
|
|
2713
|
+
empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else ("Nenhuma conta TikTok cadastrada" if destination == "TikTok" else f"Nenhum {destination} configurado")
|
|
2558
2714
|
if not options:
|
|
2559
2715
|
st.selectbox(select_label, [empty_label], disabled=True, key=f"upload_target_{destination_key}")
|
|
2560
2716
|
if destination == "YouTube":
|
|
2561
2717
|
st.caption("Cadastre ou liste pelo menos um canal YouTube antes de escolher o destino de envio.")
|
|
2718
|
+
elif destination == "TikTok":
|
|
2719
|
+
st.caption("Cadastre uma conta em Pipeline TikTok > Contas TikTok antes de escolher o destino de envio.")
|
|
2562
2720
|
else:
|
|
2563
2721
|
st.caption(f"A lista de {destination} será ligada numa etapa própria de credenciais/API.")
|
|
2564
2722
|
return None
|
|
@@ -2600,7 +2758,7 @@ def render_upload_conventional():
|
|
|
2600
2758
|
postiz_status = postiz.status()
|
|
2601
2759
|
(st.success if postiz_status.ok else st.warning)(postiz_status.message)
|
|
2602
2760
|
else:
|
|
2603
|
-
st.caption("Postiz está desactivado; active-o em
|
|
2761
|
+
st.caption("Postiz está desactivado; active-o em Configuração API para o usar como fallback final.")
|
|
2604
2762
|
status_cols = st.columns(2)
|
|
2605
2763
|
with status_cols[0]:
|
|
2606
2764
|
(st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
|
|
@@ -2703,9 +2861,239 @@ def render_upload_conventional():
|
|
|
2703
2861
|
st.button("Preparar Facebook Pages", key=f"upload_facebook_{task['id']}", disabled=True, help="UI preparada; publicação Facebook Pages ainda não está activa.")
|
|
2704
2862
|
|
|
2705
2863
|
|
|
2864
|
+
def render_google_accounts():
|
|
2865
|
+
st.title("Contas Google")
|
|
2866
|
+
st.caption("Gestão das contas Google/YouTube, documentos de credenciais, canais em lote e credenciais globais do YouTube.")
|
|
2867
|
+
settings = read_json("settings.json", {})
|
|
2868
|
+
|
|
2869
|
+
st.subheader("Contas Google/YouTube — canais em lote")
|
|
2870
|
+
st.caption("Abra uma conta para ver e editar os detalhes. O e-mail identifica a conta Google; esta área não lê a caixa Gmail.")
|
|
2871
|
+
batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
2872
|
+
channel_state = read_json("channels.json", [])
|
|
2873
|
+
youtube_accounts_missing_document: list[str] = []
|
|
2874
|
+
|
|
2875
|
+
for batch_account in batch_accounts:
|
|
2876
|
+
account_id = str(batch_account["id"])
|
|
2877
|
+
account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
|
|
2878
|
+
account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
|
|
2879
|
+
ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
|
|
2880
|
+
direct_status = direct_account_status(STORAGE, batch_account, settings)
|
|
2881
|
+
missing_document_parts = list(direct_status.get("missing_cookies", []))
|
|
2882
|
+
if not direct_status.get("has_session_info"):
|
|
2883
|
+
missing_document_parts.append("sessionInfo")
|
|
2884
|
+
if missing_document_parts:
|
|
2885
|
+
youtube_accounts_missing_document.append(account_email_snapshot)
|
|
2886
|
+
|
|
2887
|
+
with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
|
|
2888
|
+
with st.form(f"batch_account_form_{account_id}"):
|
|
2889
|
+
account_cols = st.columns(2)
|
|
2890
|
+
with account_cols[0]:
|
|
2891
|
+
account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
|
|
2892
|
+
account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
|
|
2893
|
+
account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
|
|
2894
|
+
with account_cols[1]:
|
|
2895
|
+
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}")
|
|
2896
|
+
account_session_info = st.text_input(
|
|
2897
|
+
"sessionInfo token desta conta Google",
|
|
2898
|
+
value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
|
|
2899
|
+
type="password",
|
|
2900
|
+
key=f"batch_session_info_{account_id}",
|
|
2901
|
+
help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
|
|
2902
|
+
)
|
|
2903
|
+
save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
|
|
2904
|
+
|
|
2905
|
+
st.markdown("**Documento de cookies/credenciais desta conta Google**")
|
|
2906
|
+
st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
|
|
2907
|
+
document_upload = st.file_uploader(
|
|
2908
|
+
"Subir documento de cookies/credenciais",
|
|
2909
|
+
type=["json"],
|
|
2910
|
+
key=f"direct_credentials_document_{account_id}",
|
|
2911
|
+
help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
|
|
2912
|
+
)
|
|
2913
|
+
if missing_document_parts:
|
|
2914
|
+
st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
|
|
2915
|
+
else:
|
|
2916
|
+
st.success("Documento completo para a conta Google")
|
|
2917
|
+
st.caption(f"Documento guardado em: {direct_status['document_file']}")
|
|
2918
|
+
document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
|
|
2919
|
+
|
|
2920
|
+
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
2921
|
+
status_cols = st.columns(3)
|
|
2922
|
+
with status_cols[0]:
|
|
2923
|
+
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
2924
|
+
with status_cols[1]:
|
|
2925
|
+
if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
|
|
2926
|
+
result = authorize_youtube_batch_account(batch_account, STORAGE)
|
|
2927
|
+
(st.success if result.ok else st.error)(result.message)
|
|
2928
|
+
if result.ok:
|
|
2929
|
+
st.rerun()
|
|
2930
|
+
with status_cols[2]:
|
|
2931
|
+
if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
|
|
2932
|
+
delete_youtube_batch_token(batch_account, STORAGE)
|
|
2933
|
+
delete_credentials_document(STORAGE, batch_account)
|
|
2934
|
+
remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
|
|
2935
|
+
settings["youtube_batch_accounts"] = remaining_accounts
|
|
2936
|
+
if settings.get("youtube_batch_selected_account_id") == account_id:
|
|
2937
|
+
settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
|
|
2938
|
+
for channel in channel_state:
|
|
2939
|
+
if str(channel.get("google_account_id") or "") == account_id:
|
|
2940
|
+
channel.update({"google_account_id": "", "google_account_email": ""})
|
|
2941
|
+
write_json("channels.json", channel_state)
|
|
2942
|
+
write_json("settings.json", settings)
|
|
2943
|
+
st.rerun()
|
|
2944
|
+
|
|
2945
|
+
if save_account:
|
|
2946
|
+
if "@" not in account_email.strip():
|
|
2947
|
+
st.error("Informe um e-mail Google válido.")
|
|
2948
|
+
elif not account_client_id.strip() or not account_client_secret.strip():
|
|
2949
|
+
st.error("Informe o Client ID e o Client Secret desta conta.")
|
|
2950
|
+
else:
|
|
2951
|
+
for existing in batch_accounts:
|
|
2952
|
+
if str(existing.get("id")) == account_id:
|
|
2953
|
+
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())))
|
|
2954
|
+
if credentials_changed:
|
|
2955
|
+
delete_youtube_batch_token(existing, STORAGE)
|
|
2956
|
+
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()})
|
|
2957
|
+
update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
|
|
2958
|
+
ensure_credentials_document(STORAGE, existing, settings, channel_state)
|
|
2959
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
2960
|
+
write_json("settings.json", settings)
|
|
2961
|
+
st.success("Conta Google/YouTube guardada.")
|
|
2962
|
+
st.rerun()
|
|
2963
|
+
|
|
2964
|
+
if document_save:
|
|
2965
|
+
if document_upload is None:
|
|
2966
|
+
st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
|
|
2967
|
+
else:
|
|
2968
|
+
try:
|
|
2969
|
+
merge_credentials_document(
|
|
2970
|
+
STORAGE,
|
|
2971
|
+
batch_account,
|
|
2972
|
+
document_upload.getvalue(),
|
|
2973
|
+
document_upload.name,
|
|
2974
|
+
session_info_override=str(batch_account.get("sessionInfo") or ""),
|
|
2975
|
+
channels=channel_state,
|
|
2976
|
+
)
|
|
2977
|
+
st.success("Documento incorporado e guardado nesta conta Google.")
|
|
2978
|
+
st.rerun()
|
|
2979
|
+
except ValueError as exc:
|
|
2980
|
+
st.error(str(exc))
|
|
2981
|
+
|
|
2982
|
+
if youtube_accounts_missing_document:
|
|
2983
|
+
st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
|
|
2984
|
+
|
|
2985
|
+
st.divider()
|
|
2986
|
+
st.markdown("### INNERTUBE_API_KEY")
|
|
2987
|
+
st.caption("Esta chave pertence à conta Google/YouTube seleccionada e fica guardada na configuração da conta. Não faz parte do documento de cookies/credenciais e não é editada no separador API Keys.")
|
|
2988
|
+
account_key_options = [str(account.get("id")) for account in batch_accounts if account.get("id")]
|
|
2989
|
+
account_key_labels = {str(account.get("id")): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in batch_accounts if account.get("id")}
|
|
2990
|
+
if account_key_options:
|
|
2991
|
+
with st.form("innertube_api_key_form"):
|
|
2992
|
+
selected_key_account_id = st.selectbox("Conta Google/YouTube", account_key_options, format_func=lambda value: account_key_labels.get(value, value), key="innertube_key_account")
|
|
2993
|
+
selected_key_account = next(account for account in batch_accounts if str(account.get("id")) == selected_key_account_id)
|
|
2994
|
+
current_innertube_api_key = direct_account_status(STORAGE, selected_key_account, settings).get("innertube_api_key", "")
|
|
2995
|
+
innertube_api_key_value = st.text_input("INNERTUBE_API_KEY", value=current_innertube_api_key, type="password", key=f"innertube_api_key_{selected_key_account_id}", help="Chave usada pelo Upload directo desta conta. Guarde-a aqui, separada do documento de cookies.")
|
|
2996
|
+
save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY", type="primary", use_container_width=True)
|
|
2997
|
+
if save_innertube_api_key:
|
|
2998
|
+
selected_key_account["innertube_api_key"] = innertube_api_key_value.strip()
|
|
2999
|
+
selected_key_account.pop("INNERTUBE_API_KEY", None)
|
|
3000
|
+
settings.pop("direct_innertube_api_key", None)
|
|
3001
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
3002
|
+
write_json("settings.json", settings)
|
|
3003
|
+
document = load_credentials_document(STORAGE, selected_key_account, settings, channel_state, create=True)
|
|
3004
|
+
save_credentials_document(STORAGE, selected_key_account, document)
|
|
3005
|
+
st.success("INNERTUBE_API_KEY guardada na configuração da conta Google/YouTube, fora do documento de cookies.")
|
|
3006
|
+
st.rerun()
|
|
3007
|
+
else:
|
|
3008
|
+
st.info("Adicione primeiro uma conta Google/YouTube para configurar a INNERTUBE_API_KEY.")
|
|
3009
|
+
|
|
3010
|
+
st.divider()
|
|
3011
|
+
st.markdown("### Adicionar outra conta Gmail")
|
|
3012
|
+
st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
|
|
3013
|
+
with st.form("add_batch_account_form"):
|
|
3014
|
+
add_cols = st.columns(2)
|
|
3015
|
+
with add_cols[0]:
|
|
3016
|
+
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
3017
|
+
new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
|
|
3018
|
+
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
3019
|
+
with add_cols[1]:
|
|
3020
|
+
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
3021
|
+
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 e delegated_session_ids ficam no documento; a INNERTUBE_API_KEY é configurada no bloco próprio acima.")
|
|
3022
|
+
new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
|
|
3023
|
+
add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
|
|
3024
|
+
if add_account:
|
|
3025
|
+
document_error = ""
|
|
3026
|
+
if "@" not in new_account_email.strip():
|
|
3027
|
+
st.error("Informe um e-mail Google válido.")
|
|
3028
|
+
elif not new_account_client_id.strip() or not new_account_client_secret.strip():
|
|
3029
|
+
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
3030
|
+
elif new_account_document is not None:
|
|
3031
|
+
try:
|
|
3032
|
+
raw_document = new_account_document.getvalue()
|
|
3033
|
+
json.loads(raw_document.decode("utf-8-sig", errors="replace"))
|
|
3034
|
+
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
|
|
3035
|
+
document_error = f"O documento {new_account_document.name} deve ser JSON válido."
|
|
3036
|
+
st.error(document_error)
|
|
3037
|
+
if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
|
|
3038
|
+
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()}
|
|
3039
|
+
batch_accounts.append(new_account)
|
|
3040
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
3041
|
+
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
3042
|
+
write_json("settings.json", settings)
|
|
3043
|
+
ensure_credentials_document(STORAGE, new_account, settings, channel_state)
|
|
3044
|
+
if new_account_document is not None:
|
|
3045
|
+
try:
|
|
3046
|
+
merge_credentials_document(STORAGE, new_account, new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip(), channels=channel_state)
|
|
3047
|
+
except ValueError as exc:
|
|
3048
|
+
st.error(str(exc))
|
|
3049
|
+
st.stop()
|
|
3050
|
+
st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
|
|
3051
|
+
else:
|
|
3052
|
+
st.warning(f"Conta {new_account['email']} adicionada. Foi criado um credentials.json padrão; suba o documento de cookies nesta conta quando estiver pronto.")
|
|
3053
|
+
st.rerun()
|
|
3054
|
+
|
|
3055
|
+
st.divider()
|
|
3056
|
+
st.subheader("Configuração global do YouTube")
|
|
3057
|
+
st.caption("Estas credenciais pertencem à aplicação Google/YouTube e ficam separadas das restantes APIs do Thunderbolt.")
|
|
3058
|
+
with st.form("google_global_api_settings_form"):
|
|
3059
|
+
google_api_cols = st.columns(2)
|
|
3060
|
+
with google_api_cols[0]:
|
|
3061
|
+
youtube_client_id = st.text_input(
|
|
3062
|
+
"YouTube OAuth Client ID",
|
|
3063
|
+
value=str(settings.get("youtube_client_id", "") or ""),
|
|
3064
|
+
key="google_page_youtube_client_id",
|
|
3065
|
+
help="Client ID OAuth 2.0 criado no Google Cloud para autorizar a conta YouTube.",
|
|
3066
|
+
)
|
|
3067
|
+
with google_api_cols[1]:
|
|
3068
|
+
youtube_client_secret = st.text_input(
|
|
3069
|
+
"YouTube OAuth Client Secret",
|
|
3070
|
+
value=str(settings.get("youtube_client_secret", "") or ""),
|
|
3071
|
+
type="password",
|
|
3072
|
+
key="google_page_youtube_client_secret",
|
|
3073
|
+
help="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.",
|
|
3074
|
+
)
|
|
3075
|
+
st.caption(f"OAuth local: use um cliente do tipo Desktop app. Se o Google Cloud pedir uma URI autorizada, registe exactamente `{loopback_redirect_uri()}`.")
|
|
3076
|
+
youtube_api_key = st.text_input(
|
|
3077
|
+
"YouTube Data API Key (opcional)",
|
|
3078
|
+
value=str(settings.get("youtube_api_key", "") or ""),
|
|
3079
|
+
type="password",
|
|
3080
|
+
key="google_page_youtube_api_key",
|
|
3081
|
+
help="Credencial Google Cloud separada, usada apenas para métricas oficiais da YouTube Data API.",
|
|
3082
|
+
)
|
|
3083
|
+
save_google_global = st.form_submit_button("Guardar configuração global do YouTube", type="primary", use_container_width=True)
|
|
3084
|
+
if save_google_global:
|
|
3085
|
+
settings.update({
|
|
3086
|
+
"youtube_client_id": youtube_client_id.strip(),
|
|
3087
|
+
"youtube_client_secret": youtube_client_secret.strip(),
|
|
3088
|
+
"youtube_api_key": youtube_api_key.strip(),
|
|
3089
|
+
})
|
|
3090
|
+
write_json("settings.json", settings)
|
|
3091
|
+
st.success("Configuração global do YouTube guardada em Contas Google.")
|
|
3092
|
+
st.rerun()
|
|
3093
|
+
|
|
2706
3094
|
def render_settings():
|
|
2707
|
-
st.title("
|
|
2708
|
-
st.caption("Configuração
|
|
3095
|
+
st.title("Configuração API")
|
|
3096
|
+
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.")
|
|
2709
3097
|
settings = read_json("settings.json", {})
|
|
2710
3098
|
|
|
2711
3099
|
def text_setting(label: str, key: str, *, secret: bool = False, help_text: str | None = None) -> str:
|
|
@@ -2717,209 +3105,13 @@ def render_settings():
|
|
|
2717
3105
|
key=f"settings_{key}",
|
|
2718
3106
|
)
|
|
2719
3107
|
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
with google_accounts_tab:
|
|
2723
|
-
st.subheader("Contas Google/YouTube — canais em lote")
|
|
2724
|
-
st.caption("Abra uma conta para ver e editar os detalhes. O e-mail identifica a conta Google; esta área não lê a caixa Gmail.")
|
|
2725
|
-
batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
2726
|
-
channel_state = read_json("channels.json", [])
|
|
2727
|
-
youtube_accounts_missing_document: list[str] = []
|
|
2728
|
-
|
|
2729
|
-
for batch_account in batch_accounts:
|
|
2730
|
-
account_id = str(batch_account["id"])
|
|
2731
|
-
account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
|
|
2732
|
-
account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
|
|
2733
|
-
ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
|
|
2734
|
-
direct_status = direct_account_status(STORAGE, batch_account, settings)
|
|
2735
|
-
missing_document_parts = list(direct_status.get("missing_cookies", []))
|
|
2736
|
-
if not direct_status.get("has_session_info"):
|
|
2737
|
-
missing_document_parts.append("sessionInfo")
|
|
2738
|
-
if missing_document_parts:
|
|
2739
|
-
youtube_accounts_missing_document.append(account_email_snapshot)
|
|
2740
|
-
|
|
2741
|
-
with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
|
|
2742
|
-
with st.form(f"batch_account_form_{account_id}"):
|
|
2743
|
-
account_cols = st.columns(2)
|
|
2744
|
-
with account_cols[0]:
|
|
2745
|
-
account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
|
|
2746
|
-
account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
|
|
2747
|
-
account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
|
|
2748
|
-
with account_cols[1]:
|
|
2749
|
-
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}")
|
|
2750
|
-
account_session_info = st.text_input(
|
|
2751
|
-
"sessionInfo token desta conta Google",
|
|
2752
|
-
value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
|
|
2753
|
-
type="password",
|
|
2754
|
-
key=f"batch_session_info_{account_id}",
|
|
2755
|
-
help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
|
|
2756
|
-
)
|
|
2757
|
-
save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
|
|
2758
|
-
|
|
2759
|
-
st.markdown("**Documento de cookies/credenciais desta conta Google**")
|
|
2760
|
-
st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
|
|
2761
|
-
document_upload = st.file_uploader(
|
|
2762
|
-
"Subir documento de cookies/credenciais",
|
|
2763
|
-
type=["json"],
|
|
2764
|
-
key=f"direct_credentials_document_{account_id}",
|
|
2765
|
-
help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
|
|
2766
|
-
)
|
|
2767
|
-
if missing_document_parts:
|
|
2768
|
-
st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
|
|
2769
|
-
else:
|
|
2770
|
-
st.success("Documento completo para a conta Google")
|
|
2771
|
-
st.caption(f"Documento guardado em: {direct_status['document_file']}")
|
|
2772
|
-
document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
|
|
2773
|
-
|
|
2774
|
-
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
2775
|
-
status_cols = st.columns(3)
|
|
2776
|
-
with status_cols[0]:
|
|
2777
|
-
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
2778
|
-
with status_cols[1]:
|
|
2779
|
-
if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
|
|
2780
|
-
result = authorize_youtube_batch_account(batch_account, STORAGE)
|
|
2781
|
-
(st.success if result.ok else st.error)(result.message)
|
|
2782
|
-
if result.ok:
|
|
2783
|
-
st.rerun()
|
|
2784
|
-
with status_cols[2]:
|
|
2785
|
-
if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
|
|
2786
|
-
delete_youtube_batch_token(batch_account, STORAGE)
|
|
2787
|
-
delete_credentials_document(STORAGE, batch_account)
|
|
2788
|
-
remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
|
|
2789
|
-
settings["youtube_batch_accounts"] = remaining_accounts
|
|
2790
|
-
if settings.get("youtube_batch_selected_account_id") == account_id:
|
|
2791
|
-
settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
|
|
2792
|
-
for channel in channel_state:
|
|
2793
|
-
if str(channel.get("google_account_id") or "") == account_id:
|
|
2794
|
-
channel.update({"google_account_id": "", "google_account_email": ""})
|
|
2795
|
-
write_json("channels.json", channel_state)
|
|
2796
|
-
write_json("settings.json", settings)
|
|
2797
|
-
st.rerun()
|
|
2798
|
-
|
|
2799
|
-
if save_account:
|
|
2800
|
-
if "@" not in account_email.strip():
|
|
2801
|
-
st.error("Informe um e-mail Google válido.")
|
|
2802
|
-
elif not account_client_id.strip() or not account_client_secret.strip():
|
|
2803
|
-
st.error("Informe o Client ID e o Client Secret desta conta.")
|
|
2804
|
-
else:
|
|
2805
|
-
for existing in batch_accounts:
|
|
2806
|
-
if str(existing.get("id")) == account_id:
|
|
2807
|
-
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())))
|
|
2808
|
-
if credentials_changed:
|
|
2809
|
-
delete_youtube_batch_token(existing, STORAGE)
|
|
2810
|
-
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()})
|
|
2811
|
-
update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
|
|
2812
|
-
ensure_credentials_document(STORAGE, existing, settings, channel_state)
|
|
2813
|
-
settings["youtube_batch_accounts"] = batch_accounts
|
|
2814
|
-
write_json("settings.json", settings)
|
|
2815
|
-
st.success("Conta Google/YouTube guardada.")
|
|
2816
|
-
st.rerun()
|
|
2817
|
-
|
|
2818
|
-
if document_save:
|
|
2819
|
-
if document_upload is None:
|
|
2820
|
-
st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
|
|
2821
|
-
else:
|
|
2822
|
-
try:
|
|
2823
|
-
merge_credentials_document(
|
|
2824
|
-
STORAGE,
|
|
2825
|
-
batch_account,
|
|
2826
|
-
document_upload.getvalue(),
|
|
2827
|
-
document_upload.name,
|
|
2828
|
-
session_info_override=str(batch_account.get("sessionInfo") or ""),
|
|
2829
|
-
channels=channel_state,
|
|
2830
|
-
)
|
|
2831
|
-
st.success("Documento incorporado e guardado nesta conta Google.")
|
|
2832
|
-
st.rerun()
|
|
2833
|
-
except ValueError as exc:
|
|
2834
|
-
st.error(str(exc))
|
|
2835
|
-
|
|
2836
|
-
if youtube_accounts_missing_document:
|
|
2837
|
-
st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
|
|
2838
|
-
|
|
2839
|
-
st.divider()
|
|
2840
|
-
st.markdown("### INNERTUBE_API_KEY")
|
|
2841
|
-
st.caption("Esta chave pertence à conta Google/YouTube seleccionada e fica guardada na configuração da conta. Não faz parte do documento de cookies/credenciais e não é editada no separador API Keys.")
|
|
2842
|
-
account_key_options = [str(account.get("id")) for account in batch_accounts if account.get("id")]
|
|
2843
|
-
account_key_labels = {str(account.get("id")): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in batch_accounts if account.get("id")}
|
|
2844
|
-
if account_key_options:
|
|
2845
|
-
with st.form("innertube_api_key_form"):
|
|
2846
|
-
selected_key_account_id = st.selectbox("Conta Google/YouTube", account_key_options, format_func=lambda value: account_key_labels.get(value, value), key="innertube_key_account")
|
|
2847
|
-
selected_key_account = next(account for account in batch_accounts if str(account.get("id")) == selected_key_account_id)
|
|
2848
|
-
current_innertube_api_key = direct_account_status(STORAGE, selected_key_account, settings).get("innertube_api_key", "")
|
|
2849
|
-
innertube_api_key_value = st.text_input("INNERTUBE_API_KEY", value=current_innertube_api_key, type="password", key=f"innertube_api_key_{selected_key_account_id}", help="Chave usada pelo Upload directo desta conta. Guarde-a aqui, separada do documento de cookies.")
|
|
2850
|
-
save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY", type="primary", use_container_width=True)
|
|
2851
|
-
if save_innertube_api_key:
|
|
2852
|
-
selected_key_account["innertube_api_key"] = innertube_api_key_value.strip()
|
|
2853
|
-
selected_key_account.pop("INNERTUBE_API_KEY", None)
|
|
2854
|
-
settings.pop("direct_innertube_api_key", None)
|
|
2855
|
-
settings["youtube_batch_accounts"] = batch_accounts
|
|
2856
|
-
write_json("settings.json", settings)
|
|
2857
|
-
document = load_credentials_document(STORAGE, selected_key_account, settings, channel_state, create=True)
|
|
2858
|
-
save_credentials_document(STORAGE, selected_key_account, document)
|
|
2859
|
-
st.success("INNERTUBE_API_KEY guardada na configuração da conta Google/YouTube, fora do documento de cookies.")
|
|
2860
|
-
st.rerun()
|
|
2861
|
-
else:
|
|
2862
|
-
st.info("Adicione primeiro uma conta Google/YouTube para configurar a INNERTUBE_API_KEY.")
|
|
2863
|
-
|
|
2864
|
-
st.divider()
|
|
2865
|
-
st.markdown("### Adicionar outra conta Gmail")
|
|
2866
|
-
st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
|
|
2867
|
-
with st.form("add_batch_account_form"):
|
|
2868
|
-
add_cols = st.columns(2)
|
|
2869
|
-
with add_cols[0]:
|
|
2870
|
-
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
2871
|
-
new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
|
|
2872
|
-
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
2873
|
-
with add_cols[1]:
|
|
2874
|
-
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
2875
|
-
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 e delegated_session_ids ficam no documento; a INNERTUBE_API_KEY é configurada no bloco próprio acima.")
|
|
2876
|
-
new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
|
|
2877
|
-
add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
|
|
2878
|
-
if add_account:
|
|
2879
|
-
document_error = ""
|
|
2880
|
-
if "@" not in new_account_email.strip():
|
|
2881
|
-
st.error("Informe um e-mail Google válido.")
|
|
2882
|
-
elif not new_account_client_id.strip() or not new_account_client_secret.strip():
|
|
2883
|
-
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
2884
|
-
elif new_account_document is not None:
|
|
2885
|
-
try:
|
|
2886
|
-
raw_document = new_account_document.getvalue()
|
|
2887
|
-
json.loads(raw_document.decode("utf-8-sig", errors="replace"))
|
|
2888
|
-
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
|
|
2889
|
-
document_error = f"O documento {new_account_document.name} deve ser JSON válido."
|
|
2890
|
-
st.error(document_error)
|
|
2891
|
-
if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
|
|
2892
|
-
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()}
|
|
2893
|
-
batch_accounts.append(new_account)
|
|
2894
|
-
settings["youtube_batch_accounts"] = batch_accounts
|
|
2895
|
-
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
2896
|
-
write_json("settings.json", settings)
|
|
2897
|
-
ensure_credentials_document(STORAGE, new_account, settings, channel_state)
|
|
2898
|
-
if new_account_document is not None:
|
|
2899
|
-
try:
|
|
2900
|
-
merge_credentials_document(STORAGE, new_account, new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip(), channels=channel_state)
|
|
2901
|
-
except ValueError as exc:
|
|
2902
|
-
st.error(str(exc))
|
|
2903
|
-
st.stop()
|
|
2904
|
-
st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
|
|
2905
|
-
else:
|
|
2906
|
-
st.warning(f"Conta {new_account['email']} adicionada. Foi criado um credentials.json padrão; suba o documento de cookies nesta conta quando estiver pronto.")
|
|
2907
|
-
st.rerun()
|
|
3108
|
+
api_keys_tab, voice_test_tab = st.tabs(["API Keys", "Teste de vozes"])
|
|
2908
3109
|
|
|
2909
3110
|
with api_keys_tab:
|
|
2910
3111
|
with st.form("settings_form"):
|
|
2911
3112
|
st.subheader("API Keys")
|
|
2912
3113
|
port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
|
|
2913
3114
|
moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
|
|
2914
|
-
st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
|
|
2915
|
-
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.")
|
|
2916
|
-
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()}`.")
|
|
2917
|
-
youtube_cols = st.columns(2)
|
|
2918
|
-
with youtube_cols[0]:
|
|
2919
|
-
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.")
|
|
2920
|
-
with youtube_cols[1]:
|
|
2921
|
-
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.")
|
|
2922
|
-
|
|
2923
3115
|
with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
|
|
2924
3116
|
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.")
|
|
2925
3117
|
kaggle_cols = st.columns(3)
|
|
@@ -2942,10 +3134,6 @@ def render_settings():
|
|
|
2942
3134
|
with apify_cols[3]:
|
|
2943
3135
|
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)
|
|
2944
3136
|
|
|
2945
|
-
with st.expander("Consulta oficial de métricas — opcional"):
|
|
2946
|
-
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.")
|
|
2947
|
-
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.")
|
|
2948
|
-
|
|
2949
3137
|
with st.expander("Serviço, materiais e rede"):
|
|
2950
3138
|
cols = st.columns(2)
|
|
2951
3139
|
with cols[0]:
|
|
@@ -3102,8 +3290,7 @@ def render_settings():
|
|
|
3102
3290
|
save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
|
|
3103
3291
|
if save_all_settings:
|
|
3104
3292
|
settings.update({
|
|
3105
|
-
"port": port, "moneyprinter_path": moneyprinter_path,
|
|
3106
|
-
"youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
|
|
3293
|
+
"port": port, "moneyprinter_path": moneyprinter_path,
|
|
3107
3294
|
"kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
|
|
3108
3295
|
"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),
|
|
3109
3296
|
"log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
|
|
@@ -3171,6 +3358,32 @@ def render_settings():
|
|
|
3171
3358
|
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.")
|
|
3172
3359
|
|
|
3173
3360
|
|
|
3361
|
+
def render_notifications():
|
|
3362
|
+
st.title("Notificações")
|
|
3363
|
+
st.caption("Centro de notificações locais do Thunderbolt.")
|
|
3364
|
+
st.info("Ainda não existem notificações registadas. Os eventos de processamento, publicação e integração serão ligados aqui numa etapa posterior.")
|
|
3365
|
+
st.divider()
|
|
3366
|
+
st.subheader("Preferências de notificação")
|
|
3367
|
+
st.caption("Os canais de e-mail, push e webhook ainda não estão activos nesta versão.")
|
|
3368
|
+
st.checkbox("Notificações no painel", value=True, disabled=True, key="notifications_panel_preview")
|
|
3369
|
+
st.checkbox("Notificações por e-mail", value=False, disabled=True, key="notifications_email_preview")
|
|
3370
|
+
|
|
3371
|
+
|
|
3372
|
+
def render_models_ai_tutorial():
|
|
3373
|
+
tutorial_url = "https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md"
|
|
3374
|
+
tutorial_path = ROOT / "seed" / "references" / "guide-instagram.md"
|
|
3375
|
+
st.title("Tutorial Meta")
|
|
3376
|
+
st.caption("Guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n.")
|
|
3377
|
+
st.markdown(f"[Abrir fonte original no GitHub]({tutorial_url})")
|
|
3378
|
+
try:
|
|
3379
|
+
tutorial_content = tutorial_path.read_text(encoding="utf-8").strip()
|
|
3380
|
+
except OSError:
|
|
3381
|
+
tutorial_content = ""
|
|
3382
|
+
if not tutorial_content:
|
|
3383
|
+
st.error("O conteúdo local do tutorial não está disponível. Consulte a fonte original no GitHub.")
|
|
3384
|
+
return
|
|
3385
|
+
st.markdown(tutorial_content, unsafe_allow_html=True)
|
|
3386
|
+
|
|
3174
3387
|
def render_mcp():
|
|
3175
3388
|
st.title("MCP")
|
|
3176
3389
|
st.caption("Clientes externos, servidor MCP do Thunderbolt e a skill local ficam separados para evitar confundir funções diferentes.")
|
|
@@ -3447,6 +3660,7 @@ def main():
|
|
|
3447
3660
|
]
|
|
3448
3661
|
pipeline_tiktok_items = [
|
|
3449
3662
|
("Prompts Master", ":material/auto_awesome:", "Prompts Master"),
|
|
3663
|
+
("Contas TikTok", ":material/account_circle:", "Contas TikTok"),
|
|
3450
3664
|
]
|
|
3451
3665
|
edition_items = [
|
|
3452
3666
|
("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
|
|
@@ -3456,12 +3670,15 @@ def main():
|
|
|
3456
3670
|
models_ai_items = [
|
|
3457
3671
|
("Personagens", ":material/person:", "Personagens"),
|
|
3458
3672
|
("Redes Sociais", ":material/share:", "Redes Sociais"),
|
|
3673
|
+
("Tutorial Meta", ":material/menu_book:", "Tutorial Meta"),
|
|
3459
3674
|
]
|
|
3460
3675
|
settings_items = [
|
|
3461
3676
|
("Canais Youtube", ":material/ondemand_video:", "Canais Youtube"),
|
|
3462
3677
|
("Blueprints Youtube", ":material/library_books:", "Blueprints Youtube"),
|
|
3463
3678
|
("MCP", ":material/hub:", "MCP"),
|
|
3464
|
-
("
|
|
3679
|
+
("Contas Google", ":material/account_circle:", "Contas Google"),
|
|
3680
|
+
("Configuração API", ":material/settings:", "Configuração API"),
|
|
3681
|
+
("Notificações", ":material/notifications:", "Notificações"),
|
|
3465
3682
|
]
|
|
3466
3683
|
niche_finder_items = [
|
|
3467
3684
|
("Niche Finder Kaggle", ":material/search:", "Niche Finder Kaggle"),
|
|
@@ -3489,7 +3706,8 @@ def main():
|
|
|
3489
3706
|
"Automação": "Automação Youtube",
|
|
3490
3707
|
"Canais": "Canais Youtube",
|
|
3491
3708
|
"Blueprints": "Blueprints Youtube",
|
|
3492
|
-
"Configurações Técnicas": "
|
|
3709
|
+
"Configurações Técnicas": "Configuração API",
|
|
3710
|
+
"Contas Google/YouTube — canais em lote": "Contas Google",
|
|
3493
3711
|
}
|
|
3494
3712
|
current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
|
|
3495
3713
|
if current_page not in {item[0] for item in top_pages + pipeline_items + pipeline_tiktok_items + automation_items + edition_items + models_ai_items + niche_finder_items + settings_items}:
|
|
@@ -3545,6 +3763,7 @@ def main():
|
|
|
3545
3763
|
"Criação de Músicas": render_music_creation,
|
|
3546
3764
|
"Roteiros": render_scripts,
|
|
3547
3765
|
"Prompts Master": render_tiktok_prompt_masters,
|
|
3766
|
+
"Contas TikTok": render_tiktok_accounts,
|
|
3548
3767
|
"Automação Youtube": render_automation,
|
|
3549
3768
|
"Niche Finder Kaggle": render_niche_finder,
|
|
3550
3769
|
"Niche Finder Apify": render_niche_finder_apify,
|
|
@@ -3553,13 +3772,16 @@ def main():
|
|
|
3553
3772
|
"Cortes": render_cuts,
|
|
3554
3773
|
"Editor Python": render_python_editor,
|
|
3555
3774
|
"Models AI": lambda: render_edit_placeholder("Models AI", "Seleccione uma das abas Models AI no menu expansível."),
|
|
3775
|
+
"Tutorial Meta": render_models_ai_tutorial,
|
|
3556
3776
|
"Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
|
|
3557
3777
|
"Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
|
|
3558
3778
|
"Upload": render_upload,
|
|
3559
3779
|
"Canais Youtube": render_channels,
|
|
3560
3780
|
"Blueprints Youtube": render_blueprints,
|
|
3561
3781
|
"MCP": render_mcp,
|
|
3562
|
-
"
|
|
3782
|
+
"Contas Google": render_google_accounts,
|
|
3783
|
+
"Configuração API": render_settings,
|
|
3784
|
+
"Notificações": render_notifications,
|
|
3563
3785
|
}
|
|
3564
3786
|
renderers.get(current_page, render_dashboard)()
|
|
3565
3787
|
|