@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.
- package/MANUAL-INSTALACAO.md +10 -10
- package/README.md +8 -8
- package/app/main.py +134 -114
- package/hermes_ui/blueprints.py +3 -2
- package/integrations/youtube_direct_credentials.py +201 -16
- package/integrations/youtube_direct_upload.py +49 -26
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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**,
|
|
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 lê 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.
|
|
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 **
|
|
451
|
+
Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais 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 **
|
|
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).
|
|
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
|
-
|
|
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
|
|
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 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
|
-
|
|
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.
|
|
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.
|
|
137
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
138
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
st.
|
|
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
|
-
|
|
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.
|
|
1283
|
-
elif not
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
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
|
|
1299
|
-
st.error("
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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")
|
package/hermes_ui/blueprints.py
CHANGED
|
@@ -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":
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
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
|
-
|
|
98
|
-
except
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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":
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
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,
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return str(
|
|
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
|
-
|
|
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
|
|
76
|
-
if not _session_info(settings, account):
|
|
77
|
-
return "
|
|
78
|
-
if not
|
|
79
|
-
return "
|
|
80
|
-
if not
|
|
81
|
-
return "
|
|
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.
|
|
93
|
-
self.
|
|
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":
|
|
165
|
+
"user": {"onBehalfOfUser": self.delegated_session_id},
|
|
143
166
|
},
|
|
144
167
|
}
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
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.
|
|
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