@danhachuel/thunderbolt 0.2.44 → 0.2.46
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 +122 -106
- 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.46
|
|
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.46 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.46 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.
|
|
@@ -371,7 +371,7 @@ As credenciais devem ser inseridas apenas na configuração local. A Kaggle API
|
|
|
371
371
|
|
|
372
372
|
## OpenAI/ NVIDIA NIM — API key, Base URL e modelo
|
|
373
373
|
|
|
374
|
-
A secção **LLM — providers e modelos** mantém o provider técnico `openai`, mas a área visual passou a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. Isto permite usar a API oficial da OpenAI, a API pública NVIDIA NIM/NVIDIA Build ou qualquer servidor local OpenAI-compatible sem alterar o restante fluxo do MoneyPrinterTurbo.
|
|
374
|
+
A secção **LLM — providers e modelos** mantém o provider técnico `openai`, mas a área visual passou a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. Isto permite usar a API oficial da OpenAI, a API pública NVIDIA NIM/NVIDIA Build ou qualquer servidor local OpenAI-compatible sem alterar o restante fluxo do MoneyPrinterTurbo. O campo de modelo manual utiliza o parâmetro `help` compatível com as versões de Streamlit instaladas pelo Thunderbolt; não utiliza `help_text`.
|
|
375
375
|
|
|
376
376
|
Para usar NVIDIA NIM, abra **Configurações > Configurações Técnicas**, mantenha ou preencha a Base URL `https://integrate.api.nvidia.com/v1` e introduza a API key do NVIDIA Build. Clique em **Consultar/actualizar modelos NIM**. O Thunderbolt executa a consulta apenas nesse momento, acrescentando `/models` à Base URL, envia a API key como `Authorization: Bearer ...` e interpreta a resposta OpenAI-compatible `data[].id`. Os IDs devolvidos aparecem num selector; ao escolher um, o valor é guardado em `openai_model_name` e continua a ser enviado para o `config.toml` do MoneyPrinterTurbo.
|
|
377
377
|
|
|
@@ -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.46 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,22 +27,22 @@ 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.46
|
|
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
|
|
|
43
43
|
## OpenAI/ NVIDIA NIM — descoberta de modelos
|
|
44
44
|
|
|
45
|
-
Em **Configurações Técnicas > LLM — providers e modelos**, a área anteriormente chamada **OpenAI — API key, Base URL e modelo** passa a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. O provider interno continua a ser `openai`, para preservar a compatibilidade com o MoneyPrinterTurbo e com os restantes endpoints OpenAI-compatible.
|
|
45
|
+
Em **Configurações Técnicas > LLM — providers e modelos**, a área anteriormente chamada **OpenAI — API key, Base URL e modelo** passa a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. O campo de modelo manual usa o parâmetro `help` suportado pelas versões do Streamlit instaladas pelo Thunderbolt, evitando o erro de argumento `help_text`. O provider interno continua a ser `openai`, para preservar a compatibilidade com o MoneyPrinterTurbo e com os restantes endpoints OpenAI-compatible.
|
|
46
46
|
|
|
47
47
|
A Base URL predefinida para NVIDIA NIM é `https://integrate.api.nvidia.com/v1`. Depois de inserir a API key, clique em **Consultar/actualizar modelos NIM**. O Thunderbolt consulta a Base URL acrescentando `/models`, lê os identificadores do formato OpenAI (`data[].id`) e apresenta-os num selector. O modelo escolhido é guardado em `openai_model_name` e é sincronizado para o `config.toml` do motor.
|
|
48
48
|
|
|
@@ -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.46 install
|
|
137
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.46 doctor
|
|
138
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.46
|
|
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.46 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
163
163
|
|
|
164
164
|
## Editor Python baseado no PYEdit
|
|
165
165
|
|
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
|
|
@@ -396,7 +397,7 @@ def render_channels():
|
|
|
396
397
|
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
398
|
imported_account_id = str(imported.get("google_account_id", ""))
|
|
398
399
|
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
|
-
|
|
400
|
+
st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
|
|
400
401
|
automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
|
|
401
402
|
automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
|
|
402
403
|
description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
|
|
@@ -421,7 +422,6 @@ def render_channels():
|
|
|
421
422
|
"default_blueprint_id": blueprint.strip(),
|
|
422
423
|
"default_voice": voice.strip(),
|
|
423
424
|
"voice": voice.strip(),
|
|
424
|
-
"delegated_session_id": delegated_session_id.strip(),
|
|
425
425
|
"google_account_id": google_account_id.strip(),
|
|
426
426
|
"google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
|
|
427
427
|
"automation_on": bool(automation_on),
|
|
@@ -543,7 +543,7 @@ def render_channels():
|
|
|
543
543
|
voice_options = voice_catalog()
|
|
544
544
|
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
545
|
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
|
-
|
|
546
|
+
st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
|
|
547
547
|
automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
|
|
548
548
|
automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="manual_channel_automation_time")
|
|
549
549
|
thumbnail_url = st.text_input("URL da imagem do canal", key="manual_channel_thumbnail")
|
|
@@ -567,7 +567,6 @@ def render_channels():
|
|
|
567
567
|
"default_blueprint_id": blueprint.strip(),
|
|
568
568
|
"default_voice": voice.strip(),
|
|
569
569
|
"voice": voice.strip(),
|
|
570
|
-
"delegated_session_id": delegated_session_id.strip(),
|
|
571
570
|
"google_account_id": google_account_id.strip(),
|
|
572
571
|
"google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
|
|
573
572
|
"automation_on": bool(automation_on),
|
|
@@ -593,16 +592,33 @@ def render_channels():
|
|
|
593
592
|
if current_channel_account_id and current_channel_account_id not in channel_account_ids:
|
|
594
593
|
channel_account_ids.append(current_channel_account_id)
|
|
595
594
|
youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
st.
|
|
595
|
+
st.markdown("**Upload directo — documento da conta deste canal**")
|
|
596
|
+
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.")
|
|
597
|
+
with st.form(f"channel_direct_credentials_{channel['id']}"):
|
|
598
|
+
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']}")
|
|
599
|
+
save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
|
|
600
|
+
selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
|
|
601
|
+
if selected_channel_account:
|
|
602
|
+
selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
|
|
603
|
+
if selected_account_status["ready"]:
|
|
604
|
+
st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
|
|
605
|
+
elif not selected_account_status["document_exists"]:
|
|
606
|
+
st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
|
|
607
|
+
else:
|
|
608
|
+
missing_channel_parts = list(selected_account_status["missing_cookies"])
|
|
609
|
+
if not selected_account_status["has_session_info"]:
|
|
610
|
+
missing_channel_parts.append("sessionInfo")
|
|
611
|
+
if not selected_account_status["has_innertube_api_key"]:
|
|
612
|
+
missing_channel_parts.append("INNERTUBE_API_KEY")
|
|
613
|
+
if not selected_account_status["has_delegated_session_id"]:
|
|
614
|
+
missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
|
|
615
|
+
st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
|
|
616
|
+
else:
|
|
617
|
+
st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
|
|
618
|
+
if save_channel_direct_credentials:
|
|
619
|
+
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", ""))})
|
|
620
|
+
st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
|
|
621
|
+
st.rerun()
|
|
606
622
|
cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
|
|
607
623
|
with cols[0]:
|
|
608
624
|
if channel.get("thumbnail_url"):
|
|
@@ -1276,31 +1292,35 @@ def render_upload_direct():
|
|
|
1276
1292
|
st.write(f"**{task.get('topic', 'Sem tópico')}** — {task.get('channel_name', 'Canal')}")
|
|
1277
1293
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
1278
1294
|
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.")
|
|
1295
|
+
credential_status = document_status(STORAGE, account, channel, settings, channels) if account else None
|
|
1281
1296
|
if not account:
|
|
1282
|
-
st.warning("Este canal não tem uma conta Google associada.
|
|
1283
|
-
elif not
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1297
|
+
st.warning("Este canal não tem uma conta Google associada. Associe a conta no cartão deste canal em Canais cadastrados.")
|
|
1298
|
+
elif not credential_status["ready"]:
|
|
1299
|
+
missing_direct_parts = list(credential_status["missing_cookies"])
|
|
1300
|
+
if not credential_status["has_session_info"]:
|
|
1301
|
+
missing_direct_parts.append("sessionInfo")
|
|
1302
|
+
if not credential_status["has_innertube_api_key"]:
|
|
1303
|
+
missing_direct_parts.append("INNERTUBE_API_KEY")
|
|
1304
|
+
if not credential_status["has_delegated_session_id"]:
|
|
1305
|
+
missing_direct_parts.append("DELEGATED_SESSION_ID deste canal")
|
|
1306
|
+
st.warning(f"Documento de credenciais incompleto: {', '.join(missing_direct_parts)}")
|
|
1307
|
+
else:
|
|
1308
|
+
st.caption(f"Documento de credenciais pronto: {account.get('email', 'conta Google')} · dados do canal encontrados no documento")
|
|
1287
1309
|
direct_cols = st.columns([2.2, 1, 1])
|
|
1288
1310
|
with direct_cols[0]:
|
|
1289
1311
|
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
1290
1312
|
with direct_cols[1]:
|
|
1291
1313
|
privacy = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"direct_privacy_{task['id']}")
|
|
1292
1314
|
with direct_cols[2]:
|
|
1293
|
-
|
|
1315
|
+
st.caption("Chunk size lido do documento JSON da conta")
|
|
1294
1316
|
description = st.text_area("Descrição", value=task.get("description", ""), key=f"direct_description_{task['id']}", height=90)
|
|
1295
1317
|
if st.button("Enviar por Upload directo", type="primary", key=f"direct_upload_{task['id']}"):
|
|
1296
1318
|
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.")
|
|
1319
|
+
st.error("Associe primeiro este canal a uma conta Google no cartão Upload directo — credenciais deste canal em Canais cadastrados.")
|
|
1320
|
+
elif not document_status(STORAGE, account, channel, settings, channels)["ready"]:
|
|
1321
|
+
st.error("Complete o documento JSON desta conta, incluindo cookies, sessionInfo, INNERTUBE_API_KEY e DELEGATED_SESSION_ID deste canal.")
|
|
1302
1322
|
else:
|
|
1303
|
-
result = YouTubeDirectUploader(settings, channel, account=account, storage_root=STORAGE).upload(video_path, title=title, description=description, visibility=privacy
|
|
1323
|
+
result = YouTubeDirectUploader(settings, channel, account=account, storage_root=STORAGE).upload(video_path, title=title, description=description, visibility=privacy)
|
|
1304
1324
|
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
1325
|
uploads = read_json("uploads.json", [])
|
|
1306
1326
|
uploads.append(record)
|
|
@@ -1427,6 +1447,8 @@ def render_settings():
|
|
|
1427
1447
|
st.subheader("Contas Google/YouTube — canais em lote")
|
|
1428
1448
|
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
1449
|
batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
1450
|
+
direct_document_uploads: dict[str, Any] = {}
|
|
1451
|
+
direct_account_save_buttons: dict[str, bool] = {}
|
|
1430
1452
|
for batch_account in batch_accounts:
|
|
1431
1453
|
account_id = str(batch_account["id"])
|
|
1432
1454
|
with st.container(border=True):
|
|
@@ -1440,7 +1462,27 @@ def render_settings():
|
|
|
1440
1462
|
account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
|
|
1441
1463
|
with account_cols[3]:
|
|
1442
1464
|
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)
|
|
1465
|
+
save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
|
|
1466
|
+
direct_status = direct_account_status(STORAGE, batch_account)
|
|
1467
|
+
st.markdown("**Credenciais do Upload directo desta conta Google**")
|
|
1468
|
+
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.")
|
|
1469
|
+
direct_account_cols = st.columns([2.4, 1])
|
|
1470
|
+
with direct_account_cols[0]:
|
|
1471
|
+
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.")
|
|
1472
|
+
with direct_account_cols[1]:
|
|
1473
|
+
if direct_status["document_exists"] and direct_status["ready"]:
|
|
1474
|
+
st.success("Documento completo")
|
|
1475
|
+
elif direct_status["document_exists"]:
|
|
1476
|
+
missing_document_parts = list(direct_status["missing_cookies"])
|
|
1477
|
+
if not direct_status["has_session_info"]:
|
|
1478
|
+
missing_document_parts.append("sessionInfo")
|
|
1479
|
+
if not direct_status["has_innertube_api_key"]:
|
|
1480
|
+
missing_document_parts.append("INNERTUBE_API_KEY")
|
|
1481
|
+
st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
|
|
1482
|
+
else:
|
|
1483
|
+
st.warning("Sem documento de credenciais")
|
|
1484
|
+
st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
|
|
1485
|
+
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
1486
|
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
1445
1487
|
status_cols = st.columns([2, 1, 1])
|
|
1446
1488
|
with status_cols[0]:
|
|
@@ -1476,7 +1518,23 @@ def render_settings():
|
|
|
1476
1518
|
write_json("settings.json", settings)
|
|
1477
1519
|
st.success("Conta Google/YouTube guardada.")
|
|
1478
1520
|
st.rerun()
|
|
1521
|
+
if direct_account_save_buttons.get(account_id):
|
|
1522
|
+
uploaded_document = direct_document_uploads.get(account_id)
|
|
1523
|
+
if uploaded_document is None:
|
|
1524
|
+
st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
|
|
1525
|
+
else:
|
|
1526
|
+
try:
|
|
1527
|
+
document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name)
|
|
1528
|
+
document["account_id"] = account_id
|
|
1529
|
+
document["email"] = str(batch_account.get("email", ""))
|
|
1530
|
+
save_credentials_document(STORAGE, batch_account, document)
|
|
1531
|
+
st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
|
|
1532
|
+
st.rerun()
|
|
1533
|
+
except ValueError as exc:
|
|
1534
|
+
st.error(str(exc))
|
|
1479
1535
|
with st.form("add_batch_account_form"):
|
|
1536
|
+
st.markdown("**Credenciais do Upload directo desta conta Google**")
|
|
1537
|
+
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
1538
|
add_cols = st.columns(4)
|
|
1481
1539
|
with add_cols[0]:
|
|
1482
1540
|
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
@@ -1486,19 +1544,36 @@ def render_settings():
|
|
|
1486
1544
|
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
1487
1545
|
with add_cols[3]:
|
|
1488
1546
|
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
1489
|
-
|
|
1547
|
+
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.")
|
|
1548
|
+
st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. Não existem inputs separados para cookies ou sessionInfo.")
|
|
1549
|
+
add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
|
|
1490
1550
|
if add_account:
|
|
1551
|
+
document_error = ""
|
|
1491
1552
|
if "@" not in new_account_email.strip():
|
|
1492
1553
|
st.error("Informe um e-mail Google válido.")
|
|
1493
1554
|
elif not new_account_client_id.strip() or not new_account_client_secret.strip():
|
|
1494
1555
|
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
1495
|
-
|
|
1556
|
+
elif new_account_document is not None:
|
|
1557
|
+
try:
|
|
1558
|
+
parse_credentials_document(new_account_document.getvalue(), new_account_document.name)
|
|
1559
|
+
except ValueError as exc:
|
|
1560
|
+
document_error = str(exc)
|
|
1561
|
+
st.error(document_error)
|
|
1562
|
+
if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
|
|
1496
1563
|
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()}
|
|
1564
|
+
if new_account_document is not None:
|
|
1565
|
+
document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name)
|
|
1566
|
+
document["account_id"] = new_account["id"]
|
|
1567
|
+
document["email"] = new_account["email"]
|
|
1568
|
+
save_credentials_document(STORAGE, new_account, document)
|
|
1497
1569
|
batch_accounts.append(new_account)
|
|
1498
1570
|
settings["youtube_batch_accounts"] = batch_accounts
|
|
1499
1571
|
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
1500
1572
|
write_json("settings.json", settings)
|
|
1501
|
-
|
|
1573
|
+
if new_account_document is not None:
|
|
1574
|
+
st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
|
|
1575
|
+
else:
|
|
1576
|
+
st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
|
|
1502
1577
|
st.rerun()
|
|
1503
1578
|
|
|
1504
1579
|
with st.form("settings_form"):
|
|
@@ -1539,51 +1614,9 @@ def render_settings():
|
|
|
1539
1614
|
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
1615
|
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
1616
|
|
|
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)))
|
|
1617
|
+
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.")
|
|
1618
|
+
direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
|
|
1619
|
+
direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
|
|
1587
1620
|
|
|
1588
1621
|
with st.expander("Serviço, materiais e rede"):
|
|
1589
1622
|
cols = st.columns(2)
|
|
@@ -1619,7 +1652,7 @@ def render_settings():
|
|
|
1619
1652
|
model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
|
|
1620
1653
|
selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
|
|
1621
1654
|
if selected_model == manual_option:
|
|
1622
|
-
openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "",
|
|
1655
|
+
openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
|
|
1623
1656
|
else:
|
|
1624
1657
|
openai_model_name = selected_model
|
|
1625
1658
|
else:
|
|
@@ -1715,27 +1748,13 @@ def render_settings():
|
|
|
1715
1748
|
st.rerun()
|
|
1716
1749
|
|
|
1717
1750
|
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)
|
|
1751
|
+
if save_all_settings:
|
|
1733
1752
|
settings.update({
|
|
1734
1753
|
"port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
|
|
1735
1754
|
"youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
|
|
1736
1755
|
"kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
|
|
1737
1756
|
"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
|
-
"
|
|
1757
|
+
"direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
|
|
1739
1758
|
"log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
|
|
1740
1759
|
"endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
|
|
1741
1760
|
"llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
|
|
@@ -1754,17 +1773,14 @@ def render_settings():
|
|
|
1754
1773
|
"upload_post_auto_upload": upload_post_auto_upload,
|
|
1755
1774
|
})
|
|
1756
1775
|
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}")
|
|
1776
|
+
try:
|
|
1777
|
+
synced = sync_moneyprinter_config(settings, moneyprinter_path)
|
|
1778
|
+
if synced:
|
|
1779
|
+
st.success(f"Configurações guardadas e sincronizadas com {synced}")
|
|
1780
|
+
else:
|
|
1781
|
+
st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
|
|
1782
|
+
except Exception as exc:
|
|
1783
|
+
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
1768
1784
|
|
|
1769
1785
|
st.divider()
|
|
1770
1786
|
st.subheader("Teste de vozes")
|
|
@@ -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