@danhachuel/thunderbolt 0.2.51 → 0.2.52

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.2.51
5
+ > **Versão deste manual:** 0.2.52
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.51 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.52 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.51 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.52 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -428,7 +428,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
428
428
 
429
429
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
430
430
 
431
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.51 install`; o instalador detecta e reutiliza o que já estiver válido.
431
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.52 install`; o instalador detecta e reutiliza o que já estiver válido.
432
432
 
433
433
  ### Niche Finder Apify
434
434
 
@@ -466,13 +466,13 @@ A área **Teste de vozes**, dentro de **Configurações > Configurações Técni
466
466
 
467
467
  ## Upload directo
468
468
 
469
- Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada cartão Gmail mostra o uploader **Documento de credenciais desta conta Google** e o input **sessionInfo token desta conta Google**. O documento JSON único contém cookies, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal; o sessionInfo preenchido na conta é sincronizado no mesmo ficheiro, guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
469
+ Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um expander identificado por nome e e-mail. Dentro dele existe o uploader **Subir documento de cookies/credenciais** e o input **sessionInfo token desta conta Google**. O documento JSON único contém cookies, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal; o sessionInfo preenchido na conta é sincronizado no mesmo ficheiro, guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
470
470
 
471
471
  Em **Canais > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
472
472
 
473
- A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O parser aceita o documento JSON completo, valida os cinco cookies obrigatórios e grava-o fora de `storage/state/`, com permissões locais restritas.
473
+ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, criado automaticamente ao adicionar a conta, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O uploader aceita um documento JSON completo ou parcial; o merge actualiza somente os valores presentes e preserva os restantes. O documento é guardado fora de `storage/state/`, com permissões locais restritas.
474
474
 
475
- Associe cada canal à conta Google correcta em **Canais**; não introduza o ID delegado na UI. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
475
+ Associe cada canal à conta Google correcta em **Canais**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
476
476
 
477
477
  ## MCP e integrações externas
478
478
 
package/README.md CHANGED
@@ -22,25 +22,27 @@ A primeira versão implementa a camada UI independente com:
22
22
  | Edição | Menu expansível abaixo de Automação com Limpador de Metadados funcional, Cortes reservado e Editor Python inspirado no PYEdit para vídeos e scripts locais |
23
23
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo via sessão Frontend API com cookies/sessionInfo por conta e DELEGATED_SESSION_ID por canal, TikTok, Instagram e Facebook Pages no front end |
24
24
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
25
- | Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, repetição dos campos para novas contas, eliminação individual e diagnóstico da URI OAuth, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
25
+ | Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
26
26
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
27
27
 
28
28
  ## Upload directo — credenciais por conta e por canal
29
29
 
30
- O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada cartão Gmail possui o uploader **Documento de credenciais desta conta Google** e o campo específico **sessionInfo token desta conta Google**. O token é guardado por conta e sincronizado no `credentials.json`; o documento continua a reunir `SID`, `SSID`, `HSID`, `APISID`, `INNERTUBE_API_KEY`, `chunk_size` e o mapa `delegated_session_ids` por canal. A UI não contém inputs separados para cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID. O botão **Repetir campos para nova conta** preenche o formulário seguinte com os valores da conta actual, e **Apagar conta** remove a conta, o token OAuth, o documento de credenciais e as associações de canais.
30
+ O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
31
31
 
32
- O documento é validado e guardado por Gmail em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, com permissões locais restritas. Em **Canais > Canais cadastrados**, a UI apenas associa o canal à conta Google do documento; não mostra nem edita o `DELEGATED_SESSION_ID`. O uploader procura o ID do canal dentro do mapa `delegated_session_ids` do documento e usa-o como `pageId`/`onBehalfOfUser`. O Upload directo fica bloqueado quando faltar qualquer elemento no documento. Não existem campos técnicos do Upload directo na parte inferior de Configurações Técnicas.
32
+ O botão **Adicionar outra conta Gmail** fica abaixo de uma divisória, fora dos cartões existentes. Ao criar uma conta, o Thunderbolt cria imediatamente `credentials.json` com placeholders vazios. Um upload completo ou parcial é incorporado por merge: os cookies enviados actualizam apenas os cookies fornecidos e valores como `sessionInfo`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` existentes não são apagados. O alerta **Documento incompleto: SID, SSID, HSID, APISID, SAPISID, sessionInfo, INNERTUBE_API_KEY** permanece visível por conta quando necessário.
33
+
34
+ O documento é guardado por Gmail em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, com permissões locais restritas. Em **Canais > Canais cadastrados**, a UI apenas associa o canal à conta Google; essa associação continua permitida mesmo quando o documento está incompleto. O uploader procura o ID do canal dentro do mapa `delegated_session_ids` do documento e usa-o como `pageId`/`onBehalfOfUser`; só a operação de Upload directo é bloqueada quando faltarem os dados técnicos. Não existem campos separados de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na UI.
33
35
 
34
36
  Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs, o ficheiro é escrito com permissões locais restritas e não é incluído no Git. O Thunderbolt não extrai cookies automaticamente do browser e não envia as credenciais para o GitHub. O método é não oficial e pode deixar de funcionar se o YouTube alterar os endpoints internos.
35
37
 
36
38
 
37
39
  Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
38
40
 
39
- ## Navegação da UI 0.2.51
41
+ ## Navegação da UI 0.2.52
40
42
 
41
43
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
42
44
 
43
- Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém contas Google, sessionInfo e documentos de Upload directo; **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
45
+ Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém cartões expansíveis de contas Google, sessionInfo e documentos de Upload directo; o formulário **Adicionar outra conta Gmail** fica fora dos cartões. **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
44
46
 
45
47
  ## Canais — edição por cartão e vídeos recentes
46
48
 
@@ -72,7 +74,7 @@ A consulta só é executada quando o utilizador carrega no botão; a aplicação
72
74
 
73
75
  Para autorizar uma conta, use no Google Cloud um cliente OAuth do tipo **Desktop app**. O Thunderbolt usa o loopback local determinístico `http://127.0.0.1:8765/`, abre o browser e guarda o token por conta. Se estiver a usar um cliente do tipo Web application, adicione exactamente essa URI em **Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados**; a URI deve incluir a porta e a barra final. O erro `400: redirect_uri_mismatch` significa que a URI registada no cliente Google não coincide exactamente com a enviada pela aplicação.
74
76
 
75
- A área **Contas Google/YouTube — canais em lote** permite manter várias contas. Cada cartão tem e-mail, OAuth Client ID, OAuth Client Secret, sessionInfo e documento de Upload directo. Use **Repetir campos para nova conta** para preparar rapidamente outra conta, ou **Apagar conta** para remover a conta, os tokens e os dados directos associados. O sessionInfo é sincronizado com o documento JSON da própria conta; cookies e os restantes parâmetros de Upload directo permanecem apenas nesse documento.
77
+ A área **Contas Google/YouTube — canais em lote** permite manter várias contas em cartões expansíveis com nome e e-mail. Cada cartão tem OAuth Client ID, OAuth Client Secret, sessionInfo e documento de Upload directo; o formulário separado **Adicionar outra conta Gmail** cria o documento padrão automaticamente. Use **Apagar conta** para remover a conta, os tokens e os dados directos associados. O sessionInfo é sincronizado com o documento JSON da própria conta; cookies e os restantes parâmetros de Upload directo permanecem apenas nesse documento.
76
78
 
77
79
  ## Instalação
78
80
 
package/app/main.py CHANGED
@@ -37,7 +37,7 @@ from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesiz
37
37
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
38
38
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
39
39
  from integrations.youtube_direct_upload import YouTubeDirectUploader
40
- 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_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
41
41
  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
42
42
  from integrations.local_runtime import MoneyPrinterRuntime
43
43
  from integrations.moneyprinter_config import sync_moneyprinter_config
@@ -1914,53 +1914,60 @@ def render_settings():
1914
1914
 
1915
1915
  with google_accounts_tab:
1916
1916
  st.subheader("Contas Google/YouTube — canais em lote")
1917
- 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.")
1917
+ 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.")
1918
1918
  batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1919
- direct_document_uploads: dict[str, Any] = {}
1920
- direct_account_save_buttons: dict[str, bool] = {}
1919
+ channel_state = read_json("channels.json", [])
1920
+ youtube_accounts_missing_document: list[str] = []
1921
+
1921
1922
  for batch_account in batch_accounts:
1922
1923
  account_id = str(batch_account["id"])
1923
- with st.container(border=True):
1924
+ account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
1925
+ account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
1926
+ ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
1927
+ direct_status = direct_account_status(STORAGE, batch_account)
1928
+ missing_document_parts = list(direct_status.get("missing_cookies", []))
1929
+ if not direct_status.get("has_session_info"):
1930
+ missing_document_parts.append("sessionInfo")
1931
+ if not direct_status.get("has_innertube_api_key"):
1932
+ missing_document_parts.append("INNERTUBE_API_KEY")
1933
+ if missing_document_parts:
1934
+ youtube_accounts_missing_document.append(account_email_snapshot)
1935
+
1936
+ with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
1924
1937
  with st.form(f"batch_account_form_{account_id}"):
1925
- account_cols = st.columns(4)
1938
+ account_cols = st.columns(2)
1926
1939
  with account_cols[0]:
1927
- account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
1928
- with account_cols[1]:
1929
- account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
1930
- with account_cols[2]:
1940
+ account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
1941
+ 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}")
1931
1942
  account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1932
- with account_cols[3]:
1943
+ with account_cols[1]:
1933
1944
  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}")
1934
- account_session_info = st.text_input(
1935
- "sessionInfo token desta conta Google",
1936
- value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
1937
- type="password",
1938
- key=f"batch_session_info_{account_id}",
1939
- help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
1940
- )
1945
+ account_session_info = st.text_input(
1946
+ "sessionInfo token desta conta Google",
1947
+ value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
1948
+ type="password",
1949
+ key=f"batch_session_info_{account_id}",
1950
+ help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
1951
+ )
1941
1952
  save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
1942
- direct_status = direct_account_status(STORAGE, batch_account)
1943
- st.markdown("**Credenciais do Upload directo desta conta Google**")
1944
- 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.")
1945
- direct_account_cols = st.columns([2.4, 1])
1946
- with direct_account_cols[0]:
1947
- 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.")
1948
- with direct_account_cols[1]:
1949
- if direct_status["document_exists"] and direct_status["ready"]:
1950
- st.success("Documento completo")
1951
- elif direct_status["document_exists"]:
1952
- missing_document_parts = list(direct_status["missing_cookies"])
1953
- if not direct_status["has_session_info"]:
1954
- missing_document_parts.append("sessionInfo")
1955
- if not direct_status["has_innertube_api_key"]:
1956
- missing_document_parts.append("INNERTUBE_API_KEY")
1957
- st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1958
- else:
1959
- st.warning("Sem documento de credenciais")
1960
- st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
1961
- 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}")
1953
+
1954
+ st.markdown("**Documento de cookies/credenciais desta conta Google**")
1955
+ 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.")
1956
+ document_upload = st.file_uploader(
1957
+ "Subir documento de cookies/credenciais",
1958
+ type=["json"],
1959
+ key=f"direct_credentials_document_{account_id}",
1960
+ help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
1961
+ )
1962
+ if missing_document_parts:
1963
+ st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1964
+ else:
1965
+ st.success("Documento completo para a conta Google")
1966
+ st.caption(f"Documento guardado em: {direct_status['document_file']}")
1967
+ document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
1968
+
1962
1969
  account_status = youtube_batch_account_status(batch_account, STORAGE)
1963
- status_cols = st.columns([2, 1, 1])
1970
+ status_cols = st.columns(3)
1964
1971
  with status_cols[0]:
1965
1972
  (st.success if account_status.ok else st.warning)(account_status.message)
1966
1973
  with status_cols[1]:
@@ -1977,23 +1984,13 @@ def render_settings():
1977
1984
  settings["youtube_batch_accounts"] = remaining_accounts
1978
1985
  if settings.get("youtube_batch_selected_account_id") == account_id:
1979
1986
  settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
1980
- channels = read_json("channels.json", [])
1981
- channels_changed = False
1982
- for channel in channels:
1987
+ for channel in channel_state:
1983
1988
  if str(channel.get("google_account_id") or "") == account_id:
1984
1989
  channel.update({"google_account_id": "", "google_account_email": ""})
1985
- channels_changed = True
1986
- if channels_changed:
1987
- write_json("channels.json", channels)
1990
+ write_json("channels.json", channel_state)
1988
1991
  write_json("settings.json", settings)
1989
1992
  st.rerun()
1990
- if st.button("Repetir campos para nova conta", icon=":material/content_copy:", key=f"batch_repeat_settings_{account_id}", use_container_width=True):
1991
- st.session_state["new_batch_account_label"] = account_label
1992
- st.session_state["new_batch_account_email"] = account_email
1993
- st.session_state["new_batch_account_client_id"] = account_client_id
1994
- st.session_state["new_batch_account_client_secret"] = account_client_secret
1995
- st.session_state["new_batch_account_session_info"] = account_session_info
1996
- st.rerun()
1993
+
1997
1994
  if save_account:
1998
1995
  if "@" not in account_email.strip():
1999
1996
  st.error("Informe um e-mail Google válido.")
@@ -2007,40 +2004,47 @@ def render_settings():
2007
2004
  delete_youtube_batch_token(existing, STORAGE)
2008
2005
  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()})
2009
2006
  update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
2007
+ ensure_credentials_document(STORAGE, existing, settings, channel_state)
2010
2008
  settings["youtube_batch_accounts"] = batch_accounts
2011
2009
  write_json("settings.json", settings)
2012
2010
  st.success("Conta Google/YouTube guardada.")
2013
2011
  st.rerun()
2014
- if direct_account_save_buttons.get(account_id):
2015
- uploaded_document = direct_document_uploads.get(account_id)
2016
- if uploaded_document is None:
2017
- st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
2012
+
2013
+ if document_save:
2014
+ if document_upload is None:
2015
+ st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
2018
2016
  else:
2019
2017
  try:
2020
- document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name, session_info_override=account_session_info.strip())
2021
- document["account_id"] = account_id
2022
- document["email"] = str(batch_account.get("email", ""))
2023
- save_credentials_document(STORAGE, batch_account, document)
2024
- st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
2018
+ merge_credentials_document(
2019
+ STORAGE,
2020
+ batch_account,
2021
+ document_upload.getvalue(),
2022
+ document_upload.name,
2023
+ session_info_override=str(batch_account.get("sessionInfo") or ""),
2024
+ channels=channel_state,
2025
+ )
2026
+ st.success("Documento incorporado e guardado nesta conta Google.")
2025
2027
  st.rerun()
2026
2028
  except ValueError as exc:
2027
2029
  st.error(str(exc))
2030
+
2031
+ if youtube_accounts_missing_document:
2032
+ st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
2033
+
2034
+ st.divider()
2035
+ st.markdown("### Adicionar outra conta Gmail")
2036
+ 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.")
2028
2037
  with st.form("add_batch_account_form"):
2029
- st.markdown("**Credenciais do Upload directo desta conta Google**")
2030
- st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
2031
- add_cols = st.columns(4)
2038
+ add_cols = st.columns(2)
2032
2039
  with add_cols[0]:
2033
2040
  new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
2034
- with add_cols[1]:
2035
2041
  new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
2036
- with add_cols[2]:
2037
2042
  new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
2038
- with add_cols[3]:
2043
+ with add_cols[1]:
2039
2044
  new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
2040
- 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.")
2041
- 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.")
2042
- 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.")
2043
- add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
2045
+ 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.")
2046
+ 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.")
2047
+ add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
2044
2048
  if add_account:
2045
2049
  document_error = ""
2046
2050
  if "@" not in new_account_email.strip():
@@ -2049,25 +2053,27 @@ def render_settings():
2049
2053
  st.error("Informe o Client ID e o Client Secret da nova conta.")
2050
2054
  elif new_account_document is not None:
2051
2055
  try:
2052
- parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
2053
- except ValueError as exc:
2054
- document_error = str(exc)
2056
+ raw_document = new_account_document.getvalue()
2057
+ json.loads(raw_document.decode("utf-8-sig", errors="replace"))
2058
+ except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
2059
+ document_error = f"O documento {new_account_document.name} deve ser JSON válido."
2055
2060
  st.error(document_error)
2056
2061
  if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
2057
2062
  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()}
2058
- if new_account_document is not None:
2059
- document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
2060
- document["account_id"] = new_account["id"]
2061
- document["email"] = new_account["email"]
2062
- save_credentials_document(STORAGE, new_account, document)
2063
2063
  batch_accounts.append(new_account)
2064
2064
  settings["youtube_batch_accounts"] = batch_accounts
2065
2065
  settings["youtube_batch_selected_account_id"] = new_account["id"]
2066
2066
  write_json("settings.json", settings)
2067
+ ensure_credentials_document(STORAGE, new_account, settings, channel_state)
2067
2068
  if new_account_document is not None:
2068
- st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
2069
+ try:
2070
+ 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)
2071
+ except ValueError as exc:
2072
+ st.error(str(exc))
2073
+ st.stop()
2074
+ st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
2069
2075
  else:
2070
- st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
2076
+ 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.")
2071
2077
  st.rerun()
2072
2078
 
2073
2079
  with api_keys_tab:
@@ -32,6 +32,13 @@ def cookie_file_path(storage_root: Path, account: dict[str, Any]) -> Path:
32
32
  return account_directory(storage_root, account) / "cookies.json"
33
33
 
34
34
 
35
+ def _placeholder_to_empty(value: Any) -> str:
36
+ text = str(value or "").strip()
37
+ if text in {"...", "…", "<preencher>", "<valor>"}:
38
+ return ""
39
+ return text
40
+
41
+
35
42
  def _normalise_pairs(value: Any) -> dict[str, str]:
36
43
  pairs: dict[str, str] = {}
37
44
  if isinstance(value, dict):
@@ -40,13 +47,13 @@ def _normalise_pairs(value: Any) -> dict[str, str]:
40
47
  for key, item in value.items():
41
48
  key_text = str(key).strip()
42
49
  if key_text in COOKIE_KEYS:
43
- pairs[key_text] = str(item or "").strip()
50
+ pairs[key_text] = _placeholder_to_empty(item)
44
51
  return pairs
45
52
  if isinstance(value, list):
46
53
  for item in value:
47
54
  if isinstance(item, dict):
48
55
  name = str(item.get("name") or item.get("key") or "").strip()
49
- content = str(item.get("value") or "").strip()
56
+ content = _placeholder_to_empty(item.get("value"))
50
57
  if name in COOKIE_KEYS:
51
58
  pairs[name] = content
52
59
  return pairs
@@ -130,15 +137,15 @@ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
130
137
  for key, value in delegated_raw.items():
131
138
  if isinstance(value, dict):
132
139
  value = value.get("DELEGATED_SESSION_ID") or value.get("delegated_session_id") or ""
133
- value = str(value or "").strip()
140
+ value = _placeholder_to_empty(value)
134
141
  if value:
135
142
  delegated[str(key)] = value
136
143
  return {
137
144
  "account_id": str(raw.get("account_id") or account.get("id") or "").strip(),
138
145
  "email": str(raw.get("email") or account.get("email") or "").strip(),
139
- "sessionInfo": str(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)).strip(),
146
+ "sessionInfo": _placeholder_to_empty(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)),
140
147
  "cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
141
- "INNERTUBE_API_KEY": str(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or "").strip(),
148
+ "INNERTUBE_API_KEY": _placeholder_to_empty(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or ""),
142
149
  "chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
143
150
  "delegated_session_ids": delegated,
144
151
  }
@@ -257,6 +264,49 @@ def ensure_credentials_document(storage_root: Path, account: dict[str, Any], set
257
264
  return credentials_document_path(storage_root, account)
258
265
 
259
266
 
267
+ def merge_credentials_document(
268
+ storage_root: Path,
269
+ account: dict[str, Any],
270
+ content: bytes,
271
+ filename: str = DIRECT_DOCUMENT_NAME,
272
+ *,
273
+ session_info_override: str = "",
274
+ channels: list[dict[str, Any]] | None = None,
275
+ ) -> Path:
276
+ """Merge a complete or partial credentials JSON into the account document.
277
+
278
+ The upload may contain only cookies or the full Frontend API document. Existing
279
+ non-empty values are preserved, so uploading cookies never discards sessionInfo,
280
+ INNERTUBE_API_KEY, chunk_size or delegated channel IDs.
281
+ """
282
+ try:
283
+ raw = json.loads(content.decode("utf-8-sig", errors="replace"))
284
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
285
+ raise ValueError(f"O documento de credenciais {filename} deve ser JSON válido.") from exc
286
+ if not isinstance(raw, dict):
287
+ raise ValueError(f"O documento de credenciais {filename} deve conter um objecto JSON.")
288
+
289
+ current = load_credentials_document(storage_root, account, channels=channels, create=True)
290
+ incoming = _normalise_document(raw, {**account, "sessionInfo": session_info_override})
291
+ merged = _normalise_document(current, account)
292
+ for key in COOKIE_KEYS:
293
+ if incoming["cookies"].get(key):
294
+ merged["cookies"][key] = incoming["cookies"][key]
295
+ incoming_session = _placeholder_to_empty(session_info_override) or incoming.get("sessionInfo", "")
296
+ if incoming_session:
297
+ merged["sessionInfo"] = incoming_session
298
+ if incoming.get("INNERTUBE_API_KEY"):
299
+ merged["INNERTUBE_API_KEY"] = incoming["INNERTUBE_API_KEY"]
300
+ if "chunk_size" in raw:
301
+ merged["chunk_size"] = incoming["chunk_size"]
302
+ for key, value in incoming.get("delegated_session_ids", {}).items():
303
+ if value:
304
+ merged["delegated_session_ids"][str(key)] = value
305
+ merged["account_id"] = str(account.get("id") or merged.get("account_id") or "")
306
+ merged["email"] = str(account.get("email") or merged.get("email") or "")
307
+ return save_credentials_document(storage_root, account, merged)
308
+
309
+
260
310
  def delegated_session_id(document: dict[str, Any], channel: dict[str, Any]) -> str:
261
311
  mapping = document.get("delegated_session_ids", {}) if isinstance(document, dict) else {}
262
312
  if not isinstance(mapping, dict):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.51",
3
+ "version": "0.2.52",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",