@danhachuel/thunderbolt 0.2.45 → 0.2.47

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.45
5
+ > **Versão deste manual:** 0.2.47
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.45 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.47 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.45 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.47 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.
@@ -384,7 +384,7 @@ A API key não é gravada em logs, não é incluída no GitHub e não é enviada
384
384
  Após iniciar a aplicação, valide o seguinte percurso:
385
385
 
386
386
  1. **Início:** confirme que a UI abre, mostra o estado local, as métricas e os cards das filas do Pipeline, sem botões de acções rápidas.
387
- 2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
387
+ 2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface. No formulário **Criar blueprint a partir de link**, preencha também **Nome do Blueprint**; o campo é obrigatório e o valor fica no `name` do JSON e no nome do ficheiro criado.
388
388
  3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
389
389
  4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** com um URL `/channel/UC...`, um handle ou uma subpágina `/videos`; o parser resolve o ID, consulta a página pública e tenta o RSS quando necessário. Confirme que o resultado abre o formulário de revisão sem Data API Key. Se o canal não existir ou não fornecer metadados, confirme a mensagem clara e que o formulário de uma pesquisa anterior desaparece. A Data API é opcional e fica separada; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
390
390
  5. **Niche Finder Kaggle:** abra o menu expansível **Niche Finder**, seleccione **Niche Finder Kaggle**, defina os parâmetros dentro do conteúdo principal e confirme que não há preparação automática. Antes do clique, não deve existir download de dataset nem análise. Clique em **Analisar Nichos** para iniciar a preparação dos dados e a análise; depois altere país, engagement, datas e tags e clique novamente para aplicar os novos parâmetros. Em seguida, abra a alternativa independente **Niche Finder Apify**, configure as palavras-chave e filtros na própria aba e confirme que nada é executado antes de clicar em **Pesquisar no Apify**.
@@ -397,7 +397,7 @@ Após iniciar a aplicação, valide o seguinte percurso:
397
397
  12. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
398
398
  13. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
399
399
  14. **Configurações > Configurações Técnicas > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
400
- 15. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, adicione cada conta Google. No painel aberto **Upload directo sessão YouTube Frontend API**, preencha o `sessionInfo token` dessa conta, carregue o ficheiro de cookies e clique em **Guardar cookies e sessionInfo por conta**. Em **Canais > Canais cadastrados**, use o painel aberto **Upload directo conta e canal** para preencher o `DELEGATED_SESSION_ID deste canal (individual)` e clique em **Guardar credenciais deste canal**. Configure `INNERTUBE_API_KEY`, teste apenas com um vídeo de validação e confirme que o botão fica disponível quando os três dados cookies, sessionInfo e ID delegado estão configurados. O método envia chunks de 256 KiB e não extrai cookies do navegador.
400
+ 15. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada cartão Gmail apresenta apenas o uploader **Documento de credenciais desta conta Google**. O documento JSON reúne `SID`, `SSID`, `HSID`, `APISID`, `SAPISID`, `sessionInfo`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` por canal. Em **Canais > Canais cadastrados**, associe cada canal à conta Google do documento; a UI não mostra nem edita o `DELEGATED_SESSION_ID`. O documento é guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, e o uploader todos os valores exclusivamente desse documento. Não existem campos técnicos do Upload directo na parte inferior da UI e o método não extrai cookies automaticamente do navegador.
401
401
  16. **Configurações > MCP > Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
402
402
  17. **Configurações > MCP > Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
403
403
  18. **Configurações > MCP > Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
@@ -418,7 +418,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
418
418
 
419
419
  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.
420
420
 
421
- 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.45 install`; o instalador detecta e reutiliza o que já estiver válido.
421
+ 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.47 install`; o instalador detecta e reutiliza o que já estiver válido.
422
422
 
423
423
  ### Niche Finder Apify
424
424
 
@@ -448,13 +448,13 @@ A área **Teste de vozes**, dentro de **Configurações > Configurações Técni
448
448
 
449
449
  ## Upload directo
450
450
 
451
- Em **Canais > Canais cadastrados**, o painel **Upload directo conta e canal** fica aberto por defeito em cada cartão. Seleccione a conta Google responsável pelo canal, preencha o campo **DELEGATED_SESSION_ID deste canal (individual)** e clique em **Guardar credenciais deste canal**. Este valor não é global: cada canal deve ter o seu próprio ID delegado.
451
+ Em **Configurações > Configurações Técnicas > Contas Google/YouTubecanais em lote**, cada cartão Gmail mostra apenas o uploader **Documento de credenciais desta conta Google**. O documento JSON único pode conter cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal. Os valores nunca aparecem como inputs separados; o ficheiro validado é guardado por conta em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
452
452
 
453
- Em **Configurações Técnicas > Upload directo — sessão YouTube Frontend API**, o painel fica aberto por defeito. Para cada conta listada, preencha o **sessionInfo token**, carregue em **Ficheiro de cookies** o ficheiro JSON/lista JSON/Netscape que contém `SID`, `SSID`, `HSID`, `APISID` e `SAPISID`, e clique em **Guardar cookies e sessionInfo por conta**. O ficheiro é gravado separadamente em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`; não é uma configuração global. Se ainda não existir nenhuma conta, adicione-a primeiro em **Contas Google/YouTube canais em lote**.
453
+ 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.
454
454
 
455
- A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). As credenciais deixam de ser globais: cada conta Google/YouTube cadastrada tem um ficheiro de cookies próprio e um `sessionInfo token` próprio. O carregador aceita três representações: objecto JSON com os cookies, lista JSON de cookies e formato Netscape; os cinco nomes obrigatórios são `SID`, `SSID`, `HSID`, `APISID` e `SAPISID`. O ficheiro validado é guardado em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`, fora de `storage/state/`, com permissões locais restritas.
455
+ 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.
456
456
 
457
- Em **Canais > Upload directo — conta e canal**, associe cada canal à conta Google que detém os cookies e o `sessionInfo token`, e preencha o `DELEGATED_SESSION_ID` individual desse canal. O upload os cookies e o token da conta associada e usa o ID delegado do canal como `pageId`/destino. No momento do upload, o Thunderbolt bloqueia a operação se não existir conta associada, ficheiro de cookies válido, `sessionInfo token`, `DELEGATED_SESSION_ID`, `INNERTUBE_API_KEY` ou vídeo elegível. O tamanho de chunk é validado e 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.
457
+ 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.
458
458
 
459
459
  ## MCP e integrações externas
460
460
 
package/README.md CHANGED
@@ -27,16 +27,16 @@ A primeira versão implementa a camada UI independente com:
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 dois níveis de configuração. Em **Configurações Técnicas > Upload directosessão YouTube Frontend API**, o painel fica aberto por defeito e cada conta Google cadastrada possui o seu próprio ficheiro de cookies e o seu próprio `sessionInfo token`. Primeiro adicione a conta em **Contas Google/YouTube canais em lote**; depois, no painel da conta, surgem o campo `sessionInfo token`, o uploader **Ficheiro de cookies**, o estado do ficheiro e o botão **Guardar cookies e sessionInfo por conta**. O ficheiro pode ser um JSON exportado do navegador, uma lista JSON de cookies ou um ficheiro Netscape; o Thunderbolt valida e guarda apenas `SID`, `SSID`, `HSID`, `APISID` e `SAPISID` em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`.
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/YouTubecanais em lote**, cada cartão Gmail possui apenas o uploader **Documento de credenciais desta conta Google**. Esse documento reúne `SID`, `SSID`, `HSID`, `APISID`, `SAPISID`, `sessionInfo`, `INNERTUBE_API_KEY`, `chunk_size` e o mapa `delegated_session_ids` por canal. A UI não contém inputs separados para nenhum destes valores.
31
31
 
32
- Em **Canais > Canais cadastrados**, o painel **Upload directo conta e canal** fica aberto por defeito em cada cartão. Cada canal é associado à conta Google que fornece os cookies e o `sessionInfo token`, e possui o seu próprio `DELEGATED_SESSION_ID` no campo **DELEGATED_SESSION_ID deste canal (individual)**. O upload os cookies e o token da conta associada e envia o ID delegado individual do canal como `pageId`/`onBehalfOfUser`. O botão é bloqueado quando falta qualquer destes elementos. O `INNERTUBE_API_KEY` e o tamanho de chunk continuam nas configurações directas; o tamanho é normalizado para múltiplos de 262144 bytes.
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.
33
33
 
34
34
  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
35
 
36
36
 
37
37
  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
38
 
39
- ## Navegação da UI 0.2.45
39
+ ## Navegação da UI 0.2.47
40
40
 
41
41
  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
42
 
@@ -133,9 +133,9 @@ thunderbolt
133
133
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
134
134
 
135
135
  ```powershell
136
- npx.cmd --yes @danhachuel/thunderbolt@0.2.45 install
137
- npx.cmd --yes @danhachuel/thunderbolt@0.2.45 doctor
138
- npx.cmd --yes @danhachuel/thunderbolt@0.2.45
136
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.47 install
137
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.47 doctor
138
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.47
139
139
  ```
140
140
 
141
141
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -159,7 +159,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
159
159
 
160
160
  A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
161
161
 
162
- **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.45 install`; o instalador detecta e reutiliza componentes já válidos.
162
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.47 install`; o instalador detecta e reutiliza componentes já válidos.
163
163
 
164
164
  ## Editor Python baseado no PYEdit
165
165
 
@@ -202,7 +202,7 @@ Para usar outro local, defina `THUNDERBOLT_STORAGE_DIR`. O estado é escrito de
202
202
 
203
203
  Coloque ficheiros `.json` em `storage/blueprints/canais`, `storage/blueprints/nichos` ou `storage/blueprints/importados`. A aba **Blueprints** relê a pasta e mostra o conteúdo estruturado. A instalação já inclui 13 Blueprints seed, copiados apenas quando o ficheiro ainda não existe; actualizações não substituem Blueprints locais. Também é possível importar através do carregador da própria interface.
204
204
 
205
- A mesma aba possui a subaba **Brandings**. No formulário **Criar blueprint a partir de link**, cole um link de canal, handle ou vídeo do YouTube, informe o nicho e o idioma e escolha entre **Apenas Blueprint** ou **Blueprint + Branding completo**. O primeiro modo grava o blueprint forense local; o segundo grava também um ficheiro de Branding com identidade do canal, handle, descrição, hashtags, keywords, prompts de imagem de perfil e banner, direcção visual de thumbnails, assets e checklist de revisão.
205
+ A mesma aba possui a subaba **Brandings**. No formulário **Criar blueprint a partir de link**, cole um link de canal, informe o **Nome do Blueprint**, o nicho e o idioma e escolha entre **Apenas Blueprint** ou **Blueprint + Branding completo**. O nome é guardado no campo `name` do JSON e também é usado no nome do ficheiro. O primeiro modo grava o blueprint forense local; o segundo grava também um ficheiro de Branding com identidade do canal, handle, descrição, hashtags, keywords, prompts de imagem de perfil e banner, direcção visual de thumbnails, assets e checklist de revisão.
206
206
 
207
207
  O fluxo foi modelado a partir do blueprint de clonagem com Branding anexado, incluindo a distinção entre entrada de canal/vídeo, normalização do link, metadados de nicho/idioma, perfil do canal, estratégia de conteúdo, pesquisa, identidade visual e brand pack. Placeholders de serviços externos são tratados como configuração local; chaves presentes em workflows importados não devem ser commitadas.
208
208
 
package/app/main.py CHANGED
@@ -7,6 +7,7 @@ from datetime import date, datetime
7
7
  import sys
8
8
  import uuid
9
9
  from pathlib import Path
10
+ from typing import Any
10
11
 
11
12
  import requests
12
13
  import streamlit as st
@@ -35,7 +36,7 @@ from hermes_ui.music import list_music_files, materialize_suno_audio, request_su
35
36
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
36
37
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
37
38
  from integrations.youtube_direct_upload import YouTubeDirectUploader
38
- from integrations.youtube_direct_credentials import COOKIE_KEYS as DIRECT_COOKIE_KEYS, direct_account_status, save_cookie_file
39
+ from integrations.youtube_direct_credentials import direct_account_status, document_status, parse_credentials_document, save_credentials_document
39
40
  from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels
40
41
  from integrations.local_runtime import MoneyPrinterRuntime
41
42
  from integrations.moneyprinter_config import sync_moneyprinter_config
@@ -253,21 +254,25 @@ def render_blueprints():
253
254
  st.subheader("Criar blueprint a partir de link")
254
255
  with st.form("create_blueprint_from_link"):
255
256
  source_url = st.text_input("Link do canal ou vídeo YouTube", placeholder="https://www.youtube.com/@canal ou https://youtu.be/video")
257
+ blueprint_name = st.text_input("Nome do Blueprint", placeholder="Ex.: Filosofia sombria — Canal X")
256
258
  channel_name = st.text_input("Nome do canal, se conhecido")
257
259
  niche = st.text_input("Nicho alvo", placeholder="Ex.: filosofia, história, finanças pessoais")
258
260
  language = st.selectbox("Idioma do blueprint", ["Português (pt-BR)", "English", "Español"])
259
261
  creation_type = st.radio("O que deseja criar?", ["Apenas Blueprint", "Blueprint + Branding completo"], horizontal=True)
260
262
  create_submitted = st.form_submit_button("Criar a partir do link", type="primary")
261
263
  if create_submitted:
262
- try:
263
- blueprint, branding = create_blueprint_from_link(source_url, niche, language, creation_type == "Blueprint + Branding completo", channel_name)
264
- blueprint_path, branding_path = save_generated_blueprint(blueprint, branding)
265
- st.success(f"Blueprint criado: {blueprint_path.name}")
266
- if branding_path:
267
- st.success(f"Branding completo criado: {branding_path.name}")
268
- st.rerun()
269
- except ValueError as exc:
270
- st.error(str(exc))
264
+ if not blueprint_name.strip():
265
+ st.error("Informe o nome do Blueprint antes de criar.")
266
+ else:
267
+ try:
268
+ blueprint, branding = create_blueprint_from_link(source_url, niche, language, creation_type == "Blueprint + Branding completo", channel_name, blueprint_name)
269
+ blueprint_path, branding_path = save_generated_blueprint(blueprint, branding)
270
+ st.success(f"Blueprint criado: {blueprint_path.name}")
271
+ if branding_path:
272
+ st.success(f"Branding completo criado: {branding_path.name}")
273
+ st.rerun()
274
+ except ValueError as exc:
275
+ st.error(str(exc))
271
276
  st.divider()
272
277
  st.subheader("Importar blueprint JSON")
273
278
  uploaded = st.file_uploader("Subir novo blueprint JSON", type=["json"], key="blueprint_upload")
@@ -396,7 +401,7 @@ def render_channels():
396
401
  voice = st.selectbox("Voz padrão do canal", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
397
402
  imported_account_id = str(imported.get("google_account_id", ""))
398
403
  google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, index=youtube_account_ids.index(imported_account_id) if imported_account_id in youtube_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item), key="yt_import_google_account_id")
399
- delegated_session_id = st.text_input("DELEGATED_SESSION_ID", value=imported.get("delegated_session_id", ""), type="password", key="yt_import_delegated_session_id")
404
+ st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
400
405
  automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
401
406
  automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
402
407
  description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
@@ -421,7 +426,6 @@ def render_channels():
421
426
  "default_blueprint_id": blueprint.strip(),
422
427
  "default_voice": voice.strip(),
423
428
  "voice": voice.strip(),
424
- "delegated_session_id": delegated_session_id.strip(),
425
429
  "google_account_id": google_account_id.strip(),
426
430
  "google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
427
431
  "automation_on": bool(automation_on),
@@ -543,7 +547,7 @@ def render_channels():
543
547
  voice_options = voice_catalog()
544
548
  voice = st.selectbox("Voz padrão do canal", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
545
549
  google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, format_func=lambda item: youtube_account_labels.get(item, item), key="manual_channel_google_account_id")
546
- delegated_session_id = st.text_input("DELEGATED_SESSION_ID", type="password", key="manual_channel_delegated_session_id")
550
+ st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
547
551
  automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
548
552
  automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="manual_channel_automation_time")
549
553
  thumbnail_url = st.text_input("URL da imagem do canal", key="manual_channel_thumbnail")
@@ -567,7 +571,6 @@ def render_channels():
567
571
  "default_blueprint_id": blueprint.strip(),
568
572
  "default_voice": voice.strip(),
569
573
  "voice": voice.strip(),
570
- "delegated_session_id": delegated_session_id.strip(),
571
574
  "google_account_id": google_account_id.strip(),
572
575
  "google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
573
576
  "automation_on": bool(automation_on),
@@ -593,16 +596,33 @@ def render_channels():
593
596
  if current_channel_account_id and current_channel_account_id not in channel_account_ids:
594
597
  channel_account_ids.append(current_channel_account_id)
595
598
  youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
596
- with st.expander("Upload directo — conta e canal", expanded=True):
597
- st.caption("Defina aqui a conta Google deste canal e o DELEGATED_SESSION_ID individual. Os cookies e o sessionInfo token são carregados na área da conta em Configurações Técnicas.")
598
- with st.form(f"channel_direct_credentials_{channel['id']}"):
599
- channel_account_id = st.selectbox("Conta Google dos cookies/sessionInfo", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel['id']}")
600
- channel_delegated_session_id = st.text_input("DELEGATED_SESSION_ID deste canal (individual)", value=str(channel.get("delegated_session_id", "")), type="password", key=f"channel_delegated_session_id_{channel['id']}")
601
- save_channel_direct_credentials = st.form_submit_button("Guardar credenciais deste canal", type="primary")
602
- if save_channel_direct_credentials:
603
- update_channel(channel["id"], {"google_account_id": channel_account_id.strip(), "google_account_email": str(youtube_accounts_by_id.get(channel_account_id, {}).get("email", "")), "delegated_session_id": channel_delegated_session_id.strip()})
604
- st.success("Conta Google e DELEGATED_SESSION_ID guardados neste canal.")
605
- st.rerun()
599
+ st.markdown("**Upload directo — documento da conta deste canal**")
600
+ st.caption("O DELEGATED_SESSION_ID deste canal é individual, mas fica apenas no documento JSON da conta Google. A UI não mostra nem edita esse valor; associe apenas o canal à conta que contém o documento.")
601
+ with st.form(f"channel_direct_credentials_{channel['id']}"):
602
+ channel_account_id = st.selectbox("Conta Google do documento deste canal", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel['id']}")
603
+ save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
604
+ selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
605
+ if selected_channel_account:
606
+ selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
607
+ if selected_account_status["ready"]:
608
+ st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
609
+ elif not selected_account_status["document_exists"]:
610
+ st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
611
+ else:
612
+ missing_channel_parts = list(selected_account_status["missing_cookies"])
613
+ if not selected_account_status["has_session_info"]:
614
+ missing_channel_parts.append("sessionInfo")
615
+ if not selected_account_status["has_innertube_api_key"]:
616
+ missing_channel_parts.append("INNERTUBE_API_KEY")
617
+ if not selected_account_status["has_delegated_session_id"]:
618
+ missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
619
+ st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
620
+ else:
621
+ st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
622
+ if save_channel_direct_credentials:
623
+ update_channel(channel["id"], {"google_account_id": channel_account_id.strip(), "google_account_email": str(youtube_accounts_by_id.get(channel_account_id, {}).get("email", ""))})
624
+ st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
625
+ st.rerun()
606
626
  cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
607
627
  with cols[0]:
608
628
  if channel.get("thumbnail_url"):
@@ -1276,31 +1296,35 @@ def render_upload_direct():
1276
1296
  st.write(f"**{task.get('topic', 'Sem tópico')}** — {task.get('channel_name', 'Canal')}")
1277
1297
  st.caption(video_path or "Sem caminho de vídeo registado")
1278
1298
  account = direct_accounts.get(str(channel.get("google_account_id", "")))
1279
- if not channel.get("delegated_session_id"):
1280
- st.warning("Este canal não tem DELEGATED_SESSION_ID configurado na aba Canais.")
1299
+ credential_status = document_status(STORAGE, account, channel, settings, channels) if account else None
1281
1300
  if not account:
1282
- st.warning("Este canal não tem uma conta Google associada. Configure-a em Canais > Upload directo conta e canal.")
1283
- elif not direct_account_status(STORAGE, account)["ready"]:
1284
- st.warning("A conta Google deste canal não tem cookies completos e sessionInfo token configurados em Configurações Técnicas.")
1285
- elif channel.get("delegated_session_id"):
1286
- st.caption(f"Credenciais directas: {account.get('email', 'conta Google')} · DELEGATED_SESSION_ID configurado")
1301
+ st.warning("Este canal não tem uma conta Google associada. Associe a conta no cartão deste canal em Canais cadastrados.")
1302
+ elif not credential_status["ready"]:
1303
+ missing_direct_parts = list(credential_status["missing_cookies"])
1304
+ if not credential_status["has_session_info"]:
1305
+ missing_direct_parts.append("sessionInfo")
1306
+ if not credential_status["has_innertube_api_key"]:
1307
+ missing_direct_parts.append("INNERTUBE_API_KEY")
1308
+ if not credential_status["has_delegated_session_id"]:
1309
+ missing_direct_parts.append("DELEGATED_SESSION_ID deste canal")
1310
+ st.warning(f"Documento de credenciais incompleto: {', '.join(missing_direct_parts)}")
1311
+ else:
1312
+ st.caption(f"Documento de credenciais pronto: {account.get('email', 'conta Google')} · dados do canal encontrados no documento")
1287
1313
  direct_cols = st.columns([2.2, 1, 1])
1288
1314
  with direct_cols[0]:
1289
1315
  title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
1290
1316
  with direct_cols[1]:
1291
1317
  privacy = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"direct_privacy_{task['id']}")
1292
1318
  with direct_cols[2]:
1293
- chunk_size = st.number_input("Chunk bytes", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)), key=f"direct_chunk_{task['id']}")
1319
+ st.caption("Chunk size lido do documento JSON da conta")
1294
1320
  description = st.text_area("Descrição", value=task.get("description", ""), key=f"direct_description_{task['id']}", height=90)
1295
1321
  if st.button("Enviar por Upload directo", type="primary", key=f"direct_upload_{task['id']}"):
1296
1322
  if not account:
1297
- st.error("Associe primeiro este canal a uma conta Google em Canais.")
1298
- elif not channel.get("delegated_session_id"):
1299
- st.error("Configure primeiro o DELEGATED_SESSION_ID individual deste canal em Canais.")
1300
- elif not direct_account_status(STORAGE, account)["ready"]:
1301
- st.error("Configure primeiro o ficheiro de cookies e o sessionInfo token desta conta em Configurações Técnicas.")
1323
+ st.error("Associe primeiro este canal a uma conta Google no cartão Upload directo — credenciais deste canal em Canais cadastrados.")
1324
+ elif not document_status(STORAGE, account, channel, settings, channels)["ready"]:
1325
+ st.error("Complete o documento JSON desta conta, incluindo cookies, sessionInfo, INNERTUBE_API_KEY e DELEGATED_SESSION_ID deste canal.")
1302
1326
  else:
1303
- result = YouTubeDirectUploader(settings, channel, account=account, storage_root=STORAGE).upload(video_path, title=title, description=description, visibility=privacy, chunk_size=int(chunk_size))
1327
+ result = YouTubeDirectUploader(settings, channel, account=account, storage_root=STORAGE).upload(video_path, title=title, description=description, visibility=privacy)
1304
1328
  record = {"task_id": task.get("id"), "channel_id": channel.get("id"), "google_account_id": account.get("id", ""), "destination": "YouTube direct frontend", "status": "published" if result.ok else "failed", "message": result.message, "data": result.data, "created_at": now()}
1305
1329
  uploads = read_json("uploads.json", [])
1306
1330
  uploads.append(record)
@@ -1427,6 +1451,8 @@ def render_settings():
1427
1451
  st.subheader("Contas Google/YouTube — canais em lote")
1428
1452
  st.caption("Cada registo representa uma conta Google que pode gerir vários canais do YouTube. O e-mail identifica a conta; esta área não lê a caixa Gmail. Cada conta tem Client ID, Client Secret e token OAuth próprios.")
1429
1453
  batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1454
+ direct_document_uploads: dict[str, Any] = {}
1455
+ direct_account_save_buttons: dict[str, bool] = {}
1430
1456
  for batch_account in batch_accounts:
1431
1457
  account_id = str(batch_account["id"])
1432
1458
  with st.container(border=True):
@@ -1440,7 +1466,27 @@ def render_settings():
1440
1466
  account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1441
1467
  with account_cols[3]:
1442
1468
  account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
1443
- save_account = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
1469
+ save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
1470
+ direct_status = direct_account_status(STORAGE, batch_account)
1471
+ st.markdown("**Credenciais do Upload directo desta conta Google**")
1472
+ st.caption("Estas credenciais pertencem apenas a este Gmail. O ficheiro contém SID, SSID, HSID, APISID e SAPISID e é guardado por ID da conta.")
1473
+ direct_account_cols = st.columns([2.4, 1])
1474
+ with direct_account_cols[0]:
1475
+ direct_document_uploads[account_id] = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key=f"direct_credentials_document_{account_id}", help="Documento JSON com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo token, INNERTUBE_API_KEY, chunk_size e mapa delegated_session_ids.")
1476
+ with direct_account_cols[1]:
1477
+ if direct_status["document_exists"] and direct_status["ready"]:
1478
+ st.success("Documento completo")
1479
+ elif direct_status["document_exists"]:
1480
+ missing_document_parts = list(direct_status["missing_cookies"])
1481
+ if not direct_status["has_session_info"]:
1482
+ missing_document_parts.append("sessionInfo")
1483
+ if not direct_status["has_innertube_api_key"]:
1484
+ missing_document_parts.append("INNERTUBE_API_KEY")
1485
+ st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1486
+ else:
1487
+ st.warning("Sem documento de credenciais")
1488
+ st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
1489
+ direct_account_save_buttons[account_id] = st.button("Guardar documento de Upload directo desta conta", type="secondary", use_container_width=True, key=f"save_direct_account_{account_id}")
1444
1490
  account_status = youtube_batch_account_status(batch_account, STORAGE)
1445
1491
  status_cols = st.columns([2, 1, 1])
1446
1492
  with status_cols[0]:
@@ -1476,7 +1522,23 @@ def render_settings():
1476
1522
  write_json("settings.json", settings)
1477
1523
  st.success("Conta Google/YouTube guardada.")
1478
1524
  st.rerun()
1525
+ if direct_account_save_buttons.get(account_id):
1526
+ uploaded_document = direct_document_uploads.get(account_id)
1527
+ if uploaded_document is None:
1528
+ st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
1529
+ else:
1530
+ try:
1531
+ document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name)
1532
+ document["account_id"] = account_id
1533
+ document["email"] = str(batch_account.get("email", ""))
1534
+ save_credentials_document(STORAGE, batch_account, document)
1535
+ st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
1536
+ st.rerun()
1537
+ except ValueError as exc:
1538
+ st.error(str(exc))
1479
1539
  with st.form("add_batch_account_form"):
1540
+ st.markdown("**Credenciais do Upload directo desta conta Google**")
1541
+ st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
1480
1542
  add_cols = st.columns(4)
1481
1543
  with add_cols[0]:
1482
1544
  new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
@@ -1486,19 +1548,36 @@ def render_settings():
1486
1548
  new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
1487
1549
  with add_cols[3]:
1488
1550
  new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
1489
- add_account = st.form_submit_button("Adicionar conta Google/YouTube", use_container_width=True)
1551
+ new_account_document = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key="new_batch_account_credentials_document", help="Documento JSON único com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo, INNERTUBE_API_KEY, chunk_size e delegated_session_ids.")
1552
+ st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. Não existem inputs separados para cookies ou sessionInfo.")
1553
+ add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
1490
1554
  if add_account:
1555
+ document_error = ""
1491
1556
  if "@" not in new_account_email.strip():
1492
1557
  st.error("Informe um e-mail Google válido.")
1493
1558
  elif not new_account_client_id.strip() or not new_account_client_secret.strip():
1494
1559
  st.error("Informe o Client ID e o Client Secret da nova conta.")
1495
- else:
1560
+ elif new_account_document is not None:
1561
+ try:
1562
+ parse_credentials_document(new_account_document.getvalue(), new_account_document.name)
1563
+ except ValueError as exc:
1564
+ document_error = str(exc)
1565
+ st.error(document_error)
1566
+ if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
1496
1567
  new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip()}
1568
+ if new_account_document is not None:
1569
+ document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name)
1570
+ document["account_id"] = new_account["id"]
1571
+ document["email"] = new_account["email"]
1572
+ save_credentials_document(STORAGE, new_account, document)
1497
1573
  batch_accounts.append(new_account)
1498
1574
  settings["youtube_batch_accounts"] = batch_accounts
1499
1575
  settings["youtube_batch_selected_account_id"] = new_account["id"]
1500
1576
  write_json("settings.json", settings)
1501
- st.success(f"Conta {new_account['email']} adicionada.")
1577
+ if new_account_document is not None:
1578
+ st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
1579
+ else:
1580
+ st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
1502
1581
  st.rerun()
1503
1582
 
1504
1583
  with st.form("settings_form"):
@@ -1539,51 +1618,9 @@ def render_settings():
1539
1618
  st.caption("A YouTube Data API Key é uma credencial Google Cloud separada do OAuth. Só é necessária se escolher o método YouTube Data API para consultar métricas oficiais. Não é necessária para Página pública — sem API Key, para autorizar OAuth ou para fazer upload.")
1540
1619
  youtube_api_key = text_setting("YouTube Data API Key (opcional)", "youtube_api_key", secret=True, help_text="Credencial separada, criada em Google Cloud > APIs e serviços > Credenciais > Chave de API. Não cole aqui o Client ID nem o Client Secret.")
1541
1620
 
1542
- direct_account_session_fields = {}
1543
- direct_cookie_uploads = {}
1544
- save_direct_credentials = False
1545
- with st.expander("Upload directo — sessão YouTube Frontend API", expanded=True):
1546
- st.caption("Os cookies e o sessionInfo token são configurados por conta Google/YouTube. O DELEGATED_SESSION_ID é configurado separadamente em cada canal.")
1547
- direct_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1548
- if not direct_accounts:
1549
- st.warning("Ainda não existe uma conta Google/YouTube cadastrada. Adicione primeiro a conta acima; depois deste formulário aparecerão, para cada Gmail, o campo de `sessionInfo token` e o uploader do ficheiro com SID, SSID, HSID, APISID e SAPISID.")
1550
- for direct_account in direct_accounts:
1551
- direct_account_id = str(direct_account["id"])
1552
- direct_status = direct_account_status(STORAGE, direct_account)
1553
- with st.container(border=True):
1554
- st.markdown(f"**{direct_account.get('label', 'Canais YouTube')}** — `{direct_account.get('email', 'sem e-mail')}`")
1555
- direct_account_cols = st.columns([1.6, 1.6, 1])
1556
- with direct_account_cols[0]:
1557
- direct_session_info_value = st.text_input("sessionInfo token", value=str(direct_account.get("direct_session_info", "")), type="password", key=f"direct_session_info_{direct_account_id}")
1558
- with direct_account_cols[1]:
1559
- direct_cookie_uploads[direct_account_id] = st.file_uploader("Ficheiro de cookies", type=["json", "txt", "cookies", "tsv"], key=f"direct_cookie_file_{direct_account_id}", help="JSON de cookies do navegador ou ficheiro Netscape com SID, SSID, HSID, APISID e SAPISID.")
1560
- with direct_account_cols[2]:
1561
- if direct_status["cookie_file_exists"] and not direct_status["missing_cookies"]:
1562
- st.success("Cookies guardados")
1563
- elif direct_status["cookie_file_exists"]:
1564
- st.warning(f"Faltam: {', '.join(direct_status['missing_cookies'])}")
1565
- else:
1566
- st.warning("Sem ficheiro de cookies")
1567
- st.caption("O ficheiro é guardado em storage/youtube_direct_accounts/<conta>/cookies.json. Os valores não são mostrados nem enviados para o Git.")
1568
- direct_account_session_fields[direct_account_id] = direct_session_info_value
1569
- if direct_accounts:
1570
- save_direct_credentials = st.form_submit_button("Guardar cookies e sessionInfo por conta", type="secondary", use_container_width=True)
1571
-
1572
- with st.container(border=True):
1573
- st.markdown("**Compatibilidade com instalação antiga — credenciais globais**")
1574
- st.caption("Use apenas para manter instalações antigas. Quando houver uma conta Google configurada, o Upload directo usa o ficheiro e o sessionInfo dessa conta.")
1575
- direct_cookie_cols = st.columns(3)
1576
- with direct_cookie_cols[0]:
1577
- direct_cookie_sid = text_setting("SID", "direct_cookie_sid", secret=True)
1578
- direct_cookie_ssid = text_setting("SSID", "direct_cookie_ssid", secret=True)
1579
- with direct_cookie_cols[1]:
1580
- direct_cookie_hsid = text_setting("HSID", "direct_cookie_hsid", secret=True)
1581
- direct_cookie_apisid = text_setting("APISID", "direct_cookie_apisid", secret=True)
1582
- with direct_cookie_cols[2]:
1583
- direct_cookie_sapisid = text_setting("SAPISID", "direct_cookie_sapisid", secret=True)
1584
- direct_session_info = text_setting("sessionInfo token global", "direct_session_info", secret=True)
1585
- direct_innertube_api_key = text_setting("INNERTUBE_API_KEY", "direct_innertube_api_key", secret=True)
1586
- direct_chunk_size = st.number_input("Chunk size (múltiplo de 262144)", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)))
1621
+ st.caption("As credenciais e parâmetros do Upload directo — cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e DELEGATED_SESSION_ID — são lidos exclusivamente do documento JSON por conta Google. Não são editados nesta UI.")
1622
+ direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
1623
+ direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
1587
1624
 
1588
1625
  with st.expander("Serviço, materiais e rede"):
1589
1626
  cols = st.columns(2)
@@ -1715,27 +1752,13 @@ def render_settings():
1715
1752
  st.rerun()
1716
1753
 
1717
1754
  save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
1718
- if save_all_settings or save_direct_credentials:
1719
- direct_cookie_error = ""
1720
- configured_direct_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1721
- for direct_account in configured_direct_accounts:
1722
- direct_account_id = str(direct_account["id"])
1723
- direct_account["direct_session_info"] = str(direct_account_session_fields.get(direct_account_id, direct_account.get("direct_session_info", "")) or "").strip()
1724
- uploaded_cookie_file = direct_cookie_uploads.get(direct_account_id)
1725
- if uploaded_cookie_file is not None:
1726
- try:
1727
- save_cookie_file(STORAGE, direct_account, uploaded_cookie_file.getvalue(), uploaded_cookie_file.name)
1728
- except ValueError as exc:
1729
- direct_cookie_error = str(exc)
1730
- settings["youtube_batch_accounts"] = configured_direct_accounts
1731
- if direct_cookie_error:
1732
- st.error(direct_cookie_error)
1755
+ if save_all_settings:
1733
1756
  settings.update({
1734
1757
  "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1735
1758
  "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
1736
1759
  "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
1737
1760
  "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
1738
- "direct_cookie_sid": direct_cookie_sid, "direct_cookie_ssid": direct_cookie_ssid, "direct_cookie_hsid": direct_cookie_hsid, "direct_cookie_apisid": direct_cookie_apisid, "direct_cookie_sapisid": direct_cookie_sapisid, "direct_session_info": direct_session_info, "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1761
+ "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1739
1762
  "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
1740
1763
  "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
1741
1764
  "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
@@ -1754,17 +1777,14 @@ def render_settings():
1754
1777
  "upload_post_auto_upload": upload_post_auto_upload,
1755
1778
  })
1756
1779
  write_json("settings.json", settings)
1757
- if direct_cookie_error:
1758
- st.warning("As restantes configurações foram guardadas, mas o ficheiro de cookies não foi substituído. Corrija o ficheiro e tente novamente.")
1759
- else:
1760
- try:
1761
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
1762
- if synced:
1763
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
1764
- else:
1765
- st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1766
- except Exception as exc:
1767
- st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1780
+ try:
1781
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
1782
+ if synced:
1783
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
1784
+ else:
1785
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1786
+ except Exception as exc:
1787
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1768
1788
 
1769
1789
  st.divider()
1770
1790
  st.subheader("Teste de vozes")
@@ -42,14 +42,15 @@ def parse_youtube_link(url: str) -> dict[str, str]:
42
42
  return {"original_url": value, "video_id": video_id, "channel_ref": channel_ref, "input_type": input_type}
43
43
 
44
44
 
45
- def create_blueprint_from_link(url: str, niche: str, language: str, include_branding: bool, channel_name: str = "") -> tuple[dict[str, Any], dict[str, Any] | None]:
45
+ def create_blueprint_from_link(url: str, niche: str, language: str, include_branding: bool, channel_name: str = "", blueprint_name: str = "") -> tuple[dict[str, Any], dict[str, Any] | None]:
46
46
  parsed = parse_youtube_link(url)
47
47
  base_name = channel_name.strip() or parsed["channel_ref"] or "Canal analisado"
48
+ display_name = blueprint_name.strip() or f"Blueprint — {base_name}"
48
49
  timestamp = datetime.now(timezone.utc).isoformat()
49
50
  blueprint_id = f"bp_{uuid.uuid4().hex[:10]}"
50
51
  blueprint = {
51
52
  "id": blueprint_id,
52
- "name": f"Blueprint — {base_name}",
53
+ "name": display_name,
53
54
  "metadata": {
54
55
  "task_type": "forensic_content_blueprint",
55
56
  "source_url": parsed["original_url"],
@@ -8,6 +8,8 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  COOKIE_KEYS = ("SID", "SSID", "HSID", "APISID", "SAPISID")
11
+ DIRECT_DOCUMENT_NAME = "credentials.json"
12
+ DEFAULT_CHUNK_SIZE = 262144
11
13
 
12
14
 
13
15
  def account_key(account: dict[str, Any]) -> str:
@@ -20,7 +22,12 @@ def account_directory(storage_root: Path, account: dict[str, Any]) -> Path:
20
22
  return Path(storage_root) / "youtube_direct_accounts" / account_key(account)
21
23
 
22
24
 
25
+ def credentials_document_path(storage_root: Path, account: dict[str, Any]) -> Path:
26
+ return account_directory(storage_root, account) / DIRECT_DOCUMENT_NAME
27
+
28
+
23
29
  def cookie_file_path(storage_root: Path, account: dict[str, Any]) -> Path:
30
+ """Legacy path retained only to migrate pre-document installations."""
24
31
  return account_directory(storage_root, account) / "cookies.json"
25
32
 
26
33
 
@@ -71,12 +78,107 @@ def parse_cookie_file(content: bytes, filename: str = "cookies.json") -> dict[st
71
78
  return {key: pairs[key] for key in COOKIE_KEYS}
72
79
 
73
80
 
74
- def save_cookie_file(storage_root: Path, account: dict[str, Any], content: bytes, filename: str = "cookies.json") -> Path:
75
- cookies = parse_cookie_file(content, filename)
76
- destination = cookie_file_path(storage_root, account)
81
+ def parse_credentials_document(content: bytes, filename: str = DIRECT_DOCUMENT_NAME) -> dict[str, Any]:
82
+ try:
83
+ raw = json.loads(content.decode("utf-8-sig", errors="replace"))
84
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
85
+ raise ValueError(f"O documento de credenciais {filename} deve ser JSON válido.") from exc
86
+ if not isinstance(raw, dict):
87
+ raise ValueError(f"O documento de credenciais {filename} deve conter um objecto JSON.")
88
+ document = _normalise_document(raw, {"id": raw.get("account_id", ""), "email": raw.get("email", "")})
89
+ missing = [key for key in COOKIE_KEYS if not document["cookies"].get(key)]
90
+ if missing:
91
+ raise ValueError(f"Faltam cookies obrigatórios no documento {filename}: {', '.join(missing)}.")
92
+ if not document["sessionInfo"]:
93
+ raise ValueError(f"Falta sessionInfo no documento {filename}.")
94
+ if not document["INNERTUBE_API_KEY"]:
95
+ raise ValueError(f"Falta INNERTUBE_API_KEY no documento {filename}.")
96
+ return document
97
+
98
+
99
+ def _safe_chunk_size(value: Any) -> int:
100
+ try:
101
+ chunk = max(DEFAULT_CHUNK_SIZE, int(value))
102
+ except (TypeError, ValueError):
103
+ chunk = DEFAULT_CHUNK_SIZE
104
+ chunk -= chunk % DEFAULT_CHUNK_SIZE
105
+ return chunk or DEFAULT_CHUNK_SIZE
106
+
107
+
108
+ def _channel_keys(channel: dict[str, Any]) -> list[str]:
109
+ keys: list[str] = []
110
+ for field in ("id", "youtube_channel_id"):
111
+ value = str(channel.get(field) or "").strip()
112
+ if value and value not in keys:
113
+ keys.append(value)
114
+ return keys
115
+
116
+
117
+ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
118
+ raw = raw if isinstance(raw, dict) else {}
119
+ cookies = _normalise_pairs(raw.get("cookies", raw))
120
+ delegated_raw = raw.get("delegated_session_ids", {})
121
+ delegated: dict[str, str] = {}
122
+ if isinstance(delegated_raw, dict):
123
+ for key, value in delegated_raw.items():
124
+ if isinstance(value, dict):
125
+ value = value.get("DELEGATED_SESSION_ID") or value.get("delegated_session_id") or ""
126
+ value = str(value or "").strip()
127
+ if value:
128
+ delegated[str(key)] = value
129
+ return {
130
+ "account_id": str(raw.get("account_id") or account.get("id") or "").strip(),
131
+ "email": str(raw.get("email") or account.get("email") or "").strip(),
132
+ "sessionInfo": str(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or "").strip(),
133
+ "cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
134
+ "INNERTUBE_API_KEY": str(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or "").strip(),
135
+ "chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
136
+ "delegated_session_ids": delegated,
137
+ }
138
+
139
+
140
+ def _legacy_document(storage_root: Path, account: dict[str, Any], settings: dict[str, Any] | None, channels: list[dict[str, Any]] | None) -> dict[str, Any]:
141
+ settings = settings or {}
142
+ legacy_cookies = load_cookie_file(storage_root, account)
143
+ if not legacy_cookies:
144
+ legacy_cookies = {
145
+ key: str(settings.get(f"direct_cookie_{key.lower()}") or "").strip()
146
+ for key in COOKIE_KEYS
147
+ }
148
+ delegated: dict[str, str] = {}
149
+ for channel in channels or []:
150
+ if str(channel.get("google_account_id") or "") != str(account.get("id") or ""):
151
+ continue
152
+ delegated_value = str(channel.get("delegated_session_id") or "").strip()
153
+ if delegated_value:
154
+ for key in _channel_keys(channel)[:1]:
155
+ delegated[key] = delegated_value
156
+ return _normalise_document({
157
+ "account_id": account.get("id"),
158
+ "email": account.get("email"),
159
+ "sessionInfo": account.get("direct_session_info") or settings.get("direct_session_info"),
160
+ "cookies": legacy_cookies,
161
+ "INNERTUBE_API_KEY": settings.get("direct_innertube_api_key"),
162
+ "chunk_size": settings.get("direct_chunk_size", DEFAULT_CHUNK_SIZE),
163
+ "delegated_session_ids": delegated,
164
+ }, account)
165
+
166
+
167
+ def save_credentials_document(storage_root: Path, account: dict[str, Any], document: dict[str, Any]) -> Path:
168
+ normalised = _normalise_document(document, account)
169
+ destination = credentials_document_path(storage_root, account)
77
170
  destination.parent.mkdir(parents=True, exist_ok=True)
171
+ payload = {
172
+ "account_id": normalised["account_id"],
173
+ "email": normalised["email"],
174
+ "sessionInfo": normalised["sessionInfo"],
175
+ "cookies": normalised["cookies"],
176
+ "INNERTUBE_API_KEY": normalised["INNERTUBE_API_KEY"],
177
+ "chunk_size": normalised["chunk_size"],
178
+ "delegated_session_ids": normalised["delegated_session_ids"],
179
+ }
78
180
  temporary = destination.with_name(f".{destination.name}.{secrets.token_hex(6)}.tmp")
79
- temporary.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
181
+ temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
80
182
  try:
81
183
  os.chmod(temporary, 0o600)
82
184
  except OSError:
@@ -89,25 +191,108 @@ def save_cookie_file(storage_root: Path, account: dict[str, Any], content: bytes
89
191
  return destination
90
192
 
91
193
 
194
+ def _read_json_document(path: Path) -> dict[str, Any] | None:
195
+ try:
196
+ raw = json.loads(path.read_text(encoding="utf-8"))
197
+ except (OSError, json.JSONDecodeError):
198
+ return None
199
+ return raw if isinstance(raw, dict) else None
200
+
201
+
202
+ def load_credentials_document(storage_root: Path, account: dict[str, Any], settings: dict[str, Any] | None = None, channels: list[dict[str, Any]] | None = None, *, create: bool = True) -> dict[str, Any]:
203
+ path = credentials_document_path(storage_root, account)
204
+ raw = _read_json_document(path) if path.exists() else None
205
+ if raw is None:
206
+ document = _legacy_document(storage_root, account, settings, channels)
207
+ if create:
208
+ save_credentials_document(storage_root, account, document)
209
+ return document
210
+ document = _normalise_document(raw, account)
211
+ changed = False
212
+ for channel in channels or []:
213
+ if str(channel.get("google_account_id") or "") != str(account.get("id") or ""):
214
+ continue
215
+ keys = _channel_keys(channel)
216
+ if keys and keys[0] not in document["delegated_session_ids"]:
217
+ document["delegated_session_ids"][keys[0]] = ""
218
+ changed = True
219
+ if changed and create:
220
+ save_credentials_document(storage_root, account, document)
221
+ return document
222
+
223
+
224
+ def ensure_credentials_document(storage_root: Path, account: dict[str, Any], settings: dict[str, Any] | None = None, channels: list[dict[str, Any]] | None = None) -> Path:
225
+ load_credentials_document(storage_root, account, settings, channels, create=True)
226
+ return credentials_document_path(storage_root, account)
227
+
228
+
229
+ def delegated_session_id(document: dict[str, Any], channel: dict[str, Any]) -> str:
230
+ mapping = document.get("delegated_session_ids", {}) if isinstance(document, dict) else {}
231
+ if not isinstance(mapping, dict):
232
+ return ""
233
+ for key in _channel_keys(channel):
234
+ value = mapping.get(key, "")
235
+ if isinstance(value, dict):
236
+ value = value.get("DELEGATED_SESSION_ID") or value.get("delegated_session_id") or ""
237
+ if str(value or "").strip():
238
+ return str(value).strip()
239
+ return ""
240
+
241
+
242
+ def document_status(storage_root: Path, account: dict[str, Any], channel: dict[str, Any] | None = None, settings: dict[str, Any] | None = None, channels: list[dict[str, Any]] | None = None) -> dict[str, Any]:
243
+ path = credentials_document_path(storage_root, account)
244
+ document = load_credentials_document(storage_root, account, settings, channels, create=True)
245
+ missing_cookies = [key for key in COOKIE_KEYS if not document["cookies"].get(key)]
246
+ delegated = delegated_session_id(document, channel or {}) if channel else ""
247
+ return {
248
+ "document_file": str(path),
249
+ "document_exists": path.exists(),
250
+ "missing_cookies": missing_cookies,
251
+ "has_session_info": bool(document.get("sessionInfo")),
252
+ "has_innertube_api_key": bool(document.get("INNERTUBE_API_KEY")),
253
+ "has_delegated_session_id": bool(delegated) if channel is not None else None,
254
+ "ready": not missing_cookies and bool(document.get("sessionInfo")) and bool(document.get("INNERTUBE_API_KEY")) and (channel is None or bool(delegated)),
255
+ }
256
+
257
+
92
258
  def load_cookie_file(storage_root: Path, account: dict[str, Any]) -> dict[str, str]:
259
+ """Load cookies from the legacy cookies.json file for migration only."""
93
260
  path = cookie_file_path(storage_root, account)
94
261
  if not path.exists():
95
262
  return {}
263
+ raw = _read_json_document(path)
264
+ return _normalise_pairs(raw or {})
265
+
266
+
267
+ def save_cookie_file(storage_root: Path, account: dict[str, Any], content: bytes, filename: str = "cookies.json") -> Path:
268
+ """Legacy helper retained for migration tests; new writes must use credentials.json."""
269
+ cookies = parse_cookie_file(content, filename)
270
+ destination = cookie_file_path(storage_root, account)
271
+ destination.parent.mkdir(parents=True, exist_ok=True)
272
+ temporary = destination.with_name(f".{destination.name}.{secrets.token_hex(6)}.tmp")
273
+ temporary.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
96
274
  try:
97
- raw = json.loads(path.read_text(encoding="utf-8"))
98
- except (OSError, json.JSONDecodeError):
99
- return {}
100
- return _normalise_pairs(raw)
275
+ os.chmod(temporary, 0o600)
276
+ except OSError:
277
+ pass
278
+ temporary.replace(destination)
279
+ try:
280
+ os.chmod(destination, 0o600)
281
+ except OSError:
282
+ pass
283
+ return destination
101
284
 
102
285
 
103
286
  def direct_account_status(storage_root: Path, account: dict[str, Any]) -> dict[str, Any]:
104
- cookies = load_cookie_file(storage_root, account)
105
- missing = [key for key in COOKIE_KEYS if not cookies.get(key)]
106
- session_info = str(account.get("direct_session_info") or "").strip()
287
+ """Backward-compatible account status; the source of truth is credentials.json."""
288
+ status = document_status(storage_root, account)
107
289
  return {
108
- "cookie_file": str(cookie_file_path(storage_root, account)),
109
- "cookie_file_exists": cookie_file_path(storage_root, account).exists(),
110
- "missing_cookies": missing,
111
- "has_session_info": bool(session_info),
112
- "ready": not missing and bool(session_info),
290
+ "cookie_file": status["document_file"],
291
+ "document_exists": status["document_exists"],
292
+ "cookie_file_exists": status["document_exists"],
293
+ "missing_cookies": status["missing_cookies"],
294
+ "has_session_info": status["has_session_info"],
295
+ "has_innertube_api_key": status["has_innertube_api_key"],
296
+ "ready": status["ready"],
297
+ "document_file": status["document_file"],
113
298
  }
@@ -9,7 +9,7 @@ from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import Any
11
11
 
12
- from integrations.youtube_direct_credentials import COOKIE_KEYS, load_cookie_file
12
+ from integrations.youtube_direct_credentials import COOKIE_KEYS, delegated_session_id, load_credentials_document
13
13
  from urllib.parse import quote
14
14
 
15
15
  import requests
@@ -26,18 +26,37 @@ class DirectUploadResult:
26
26
  data: dict[str, Any]
27
27
 
28
28
 
29
- def _cookie_settings(settings: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> dict[str, str]:
29
+ def _direct_document(settings: dict[str, Any], channel: dict[str, Any], account: dict[str, Any] | None, storage_root: Path | None) -> dict[str, Any]:
30
30
  if account is not None and storage_root is not None:
31
- account_cookies = load_cookie_file(storage_root, account)
32
- if account_cookies:
33
- return {key: str(account_cookies.get(key, "") or "").strip() for key in COOKIE_KEYS}
34
- return {key: str(settings.get(f"direct_cookie_{key.lower()}", "") or "").strip() for key in COOKIE_KEYS}
31
+ return load_credentials_document(storage_root, account, settings, [channel], create=True)
32
+ return {
33
+ "cookies": {key: str(settings.get(f"direct_cookie_{key.lower()}", "") or "").strip() for key in COOKIE_KEYS},
34
+ "sessionInfo": str(settings.get("direct_session_info", "") or "").strip(),
35
+ "INNERTUBE_API_KEY": str(settings.get("direct_innertube_api_key", "") or "").strip(),
36
+ "chunk_size": int(settings.get("direct_chunk_size", CHUNK_GRANULARITY) or CHUNK_GRANULARITY),
37
+ "delegated_session_ids": {str(channel.get("id") or ""): str(channel.get("delegated_session_id") or "").strip()},
38
+ }
35
39
 
36
40
 
37
- def _session_info(settings: dict[str, Any], account: dict[str, Any] | None = None) -> str:
38
- if account is not None:
39
- return str(account.get("direct_session_info", "") or "").strip()
40
- return str(settings.get("direct_session_info", "") or "").strip()
41
+ def _cookie_settings(settings: dict[str, Any], channel: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> dict[str, str]:
42
+ document = _direct_document(settings, channel, account, storage_root)
43
+ cookies = document.get("cookies", {})
44
+ return {key: str(cookies.get(key, "") or "").strip() for key in COOKIE_KEYS}
45
+
46
+
47
+ def _session_info(settings: dict[str, Any], channel: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> str:
48
+ return str(_direct_document(settings, channel, account, storage_root).get("sessionInfo", "") or "").strip()
49
+
50
+
51
+ def _innertube_api_key(settings: dict[str, Any], channel: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> str:
52
+ return str(_direct_document(settings, channel, account, storage_root).get("INNERTUBE_API_KEY", "") or "").strip()
53
+
54
+
55
+ def _delegated_session(settings: dict[str, Any], channel: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> str:
56
+ document = _direct_document(settings, channel, account, storage_root)
57
+ if account is not None and storage_root is not None:
58
+ return delegated_session_id(document, channel) or str(channel.get("delegated_session_id") or "").strip()
59
+ return str(channel.get("delegated_session_id") or "").strip()
41
60
 
42
61
 
43
62
  def _cookie_header(cookies: dict[str, str]) -> str:
@@ -70,15 +89,16 @@ def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], sett
70
89
  return "Formato de vídeo não suportado pelo upload directo."
71
90
  if path.stat().st_size <= 0:
72
91
  return "O ficheiro de vídeo está vazio."
73
- missing_cookies = [key for key, value in _cookie_settings(settings, account, storage_root).items() if not value]
92
+ cookies = _cookie_settings(settings, channel, account, storage_root)
93
+ missing_cookies = [key for key, value in cookies.items() if not value]
74
94
  if missing_cookies:
75
- return f"Faltam cookies de sessão para upload directo: {', '.join(missing_cookies)}."
76
- if not _session_info(settings, account):
77
- return "Configure o token sessionInfo do upload directo."
78
- if not str(settings.get("direct_innertube_api_key", "") or "").strip():
79
- return "Configure o INNERTUBE_API_KEY do upload directo."
80
- if not str(channel.get("delegated_session_id", "") or "").strip():
81
- return "Este canal não tem DELEGATED_SESSION_ID configurado."
95
+ return f"Faltam cookies no documento de credenciais da conta: {', '.join(missing_cookies)}."
96
+ if not _session_info(settings, channel, account, storage_root):
97
+ return "Falta sessionInfo no documento de credenciais da conta."
98
+ if not _innertube_api_key(settings, channel, account, storage_root):
99
+ return "Falta INNERTUBE_API_KEY no documento de credenciais da conta."
100
+ if not _delegated_session(settings, channel, account, storage_root):
101
+ return "Falta DELEGATED_SESSION_ID deste canal no documento de credenciais da conta."
82
102
  return None
83
103
 
84
104
 
@@ -89,8 +109,11 @@ class YouTubeDirectUploader:
89
109
  self.account = account
90
110
  self.storage_root = storage_root
91
111
  self.session = session or requests.Session()
92
- self.cookies = _cookie_settings(settings, account, storage_root)
93
- self.session_info = _session_info(settings, account)
112
+ self.document = _direct_document(settings, channel, account, storage_root)
113
+ self.cookies = _cookie_settings(settings, channel, account, storage_root)
114
+ self.session_info = _session_info(settings, channel, account, storage_root)
115
+ self.delegated_session_id = _delegated_session(settings, channel, account, storage_root)
116
+ self.innertube_api_key = _innertube_api_key(settings, channel, account, storage_root)
94
117
  self.cookie_header = _cookie_header(self.cookies)
95
118
  self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36"
96
119
  self.inner_tube = _innertube_id()
@@ -139,12 +162,11 @@ class YouTubeDirectUploader:
139
162
  "context": {
140
163
  "client": {"clientName": 62, "clientVersion": "1.20210806.02.00", "hl": "pt-BR", "gl": "BR", "experimentsToken": "", "utcOffsetMinutes": 0},
141
164
  "request": {"sessionInfo": {"token": self.session_info}},
142
- "user": {"onBehalfOfUser": str(self.channel.get("delegated_session_id", ""))},
165
+ "user": {"onBehalfOfUser": self.delegated_session_id},
143
166
  },
144
167
  }
145
- api_key = str(self.settings.get("direct_innertube_api_key", "")).strip()
146
- endpoint = f"https://studio.youtube.com/youtubei/v1/upload/createvideo?alt=json&key={quote(api_key)}"
147
- headers = {**self._base_headers(), "Content-Type": "application/json", "X-Youtube-Client-Name": "62", "X-Youtube-Client-Version": "1.20210806.02.00", "X-Goog-PageId": str(self.channel.get("delegated_session_id", "")), "Authorization": f"SAPISIDHASH {_sapishash(self.cookies['SAPISID'])}"}
168
+ endpoint = f"https://studio.youtube.com/youtubei/v1/upload/createvideo?alt=json&key={quote(self.innertube_api_key)}"
169
+ headers = {**self._base_headers(), "Content-Type": "application/json", "X-Youtube-Client-Name": "62", "X-Youtube-Client-Version": "1.20210806.02.00", "X-Goog-PageId": self.delegated_session_id, "Authorization": f"SAPISIDHASH {_sapishash(self.cookies['SAPISID'])}"}
148
170
  response = self.session.post(endpoint, headers=headers, data=json.dumps(payload), timeout=60)
149
171
  response.raise_for_status()
150
172
  body = response.json() if response.content else {}
@@ -169,7 +191,7 @@ class YouTubeDirectUploader:
169
191
  response.raise_for_status()
170
192
  offset += len(chunk)
171
193
 
172
- def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "private", chunk_size: int = CHUNK_GRANULARITY) -> DirectUploadResult:
194
+ def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "private", chunk_size: int | None = None) -> DirectUploadResult:
173
195
  path = Path(video_path)
174
196
  validation_error = validate_direct_upload(path, self.channel, self.settings, self.account, self.storage_root)
175
197
  if validation_error:
@@ -177,7 +199,8 @@ class YouTubeDirectUploader:
177
199
  try:
178
200
  self.describe_file(path)
179
201
  self.create_video(title, description, visibility)
180
- self.upload_chunks(path, chunk_size)
202
+ configured_chunk_size = int(chunk_size or self.document.get("chunk_size") or CHUNK_GRANULARITY)
203
+ self.upload_chunks(path, configured_chunk_size)
181
204
  return DirectUploadResult(True, f"Upload directo concluído: {self.video_id}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id, "page_id": self.channel.get("delegated_session_id", ""), "google_account_id": (self.account or {}).get("id", "")})
182
205
  except (requests.RequestException, OSError, ValueError, RuntimeError) as exc:
183
206
  return DirectUploadResult(False, f"Upload directo falhou: {exc}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
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",