@danhachuel/thunderbolt 0.2.40 → 0.2.42
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 +17 -7
- package/README.md +17 -8
- package/app/main.py +282 -26
- package/hermes_ui/domain.py +3 -0
- package/hermes_ui/storage.py +2 -0
- package/integrations/youtube_batch.py +185 -0
- package/integrations/youtube_direct_credentials.py +113 -0
- package/integrations/youtube_direct_upload.py +24 -10
- 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.42
|
|
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.42 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.42 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.
|
|
@@ -379,15 +379,23 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
379
379
|
7. **Criação de Vídeos > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba. Confirme também que **Criação de Músicas** mostra o mesmo fluxo com título próprio.
|
|
380
380
|
8. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
|
|
381
381
|
9. **Edição > Limpador de Metadados:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos das páginas de criação não são aceites nesta área.
|
|
382
|
-
10. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
382
|
+
10. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git. Na secção **Contas Google/YouTube — canais em lote**, adicione cada conta com o seu e-mail/Gmail, OAuth Client ID e OAuth Client Secret próprios; o e-mail identifica a conta e não é usado para ler a caixa Gmail.
|
|
383
383
|
11. **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`.
|
|
384
384
|
12. **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.
|
|
385
385
|
13. **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.
|
|
386
|
-
14. **Upload directo:**
|
|
386
|
+
14. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, seleccione cada conta Google e carregue o seu ficheiro de cookies no painel **Upload directo — sessão YouTube Frontend API**. São aceites JSON de navegador, lista JSON e formato Netscape; o Thunderbolt valida `SID`, `SSID`, `HSID`, `APISID` e `SAPISID` e guarda o ficheiro em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`. Preencha também o `sessionInfo token` da mesma conta. Em **Canais > Upload directo — conta e canal**, associe o canal à conta e preencha o `DELEGATED_SESSION_ID` individual. Configure `INNERTUBE_API_KEY`, teste apenas com um vídeo de validação e confirme que o botão só fica disponível quando os três dados — cookies, sessionInfo e ID delegado — estão configurados. O método envia chunks de 256 KiB e não extrai cookies do navegador.
|
|
387
387
|
15. **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.
|
|
388
388
|
16. **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.
|
|
389
389
|
17. **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.
|
|
390
390
|
|
|
391
|
+
## Canais em lote por conta Google/YouTube
|
|
392
|
+
|
|
393
|
+
A subaba **Canais em lote gmail**, dentro de **Canais**, não lê e-mails, mensagens, contactos ou a caixa Gmail. O nome identifica a conta Google/Gmail que gere os canais YouTube. Configure cada conta em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, preenchendo e-mail, OAuth Client ID e OAuth Client Secret.
|
|
394
|
+
|
|
395
|
+
Na subaba, seleccione a conta e clique em **Autorizar conta Google**. A autorização abre o browser do sistema e guarda um refresh token separado para essa conta. Depois clique em **Listar canais desta conta**. O Thunderbolt chama a YouTube Data API com `channels.list`, `mine=true`, `part=snippet,contentDetails,statistics`, suporta páginas sucessivas e apresenta os canais encontrados para selecção.
|
|
396
|
+
|
|
397
|
+
Escolha os canais e os defaults de Blueprint, voz, idioma e estilo wide, depois clique em **Cadastrar canais seleccionados**. A importação usa `youtube_channel_id` para não duplicar canais já cadastrados, preserva os registos existentes e identifica a origem como `youtube_data_api_oauth_mine`. A API Key global não substitui OAuth neste fluxo; permanece reservada para consultas públicas/métricas.
|
|
398
|
+
|
|
391
399
|
## Niche Finder Kaggle e Niche Finder Apify
|
|
392
400
|
|
|
393
401
|
A página **Niche Finder Kaggle** integra a lógica adaptada do projecto open source [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder), cujo projecto original usa K-Means e FP-Growth sobre o dataset público [Trending Youtube Video Statistics (113 Countries)](https://www.kaggle.com/datasets/asaniczka/trending-youtube-videos-113-countries). No Thunderbolt não existe Flask, rota HTTP adicional, template HTML, JavaScript D3 ou segundo processo; toda a análise é síncrona no Streamlit.
|
|
@@ -396,7 +404,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
396
404
|
|
|
397
405
|
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.
|
|
398
406
|
|
|
399
|
-
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.
|
|
407
|
+
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.42 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
400
408
|
|
|
401
409
|
### Niche Finder Apify
|
|
402
410
|
|
|
@@ -426,7 +434,9 @@ A área **Teste de vozes**, dentro de **Configurações > Configurações Técni
|
|
|
426
434
|
|
|
427
435
|
## Upload directo
|
|
428
436
|
|
|
429
|
-
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api).
|
|
437
|
+
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). As credenciais deixam de ser globais: cada conta Google/YouTube cadastrada em **Configurações Técnicas > Contas Google/YouTube — canais em lote** tem um ficheiro de cookies próprio e um `sessionInfo token` próprio. O carregador aceita três representações: objecto JSON com os cookies, lista JSON de cookies e formato Netscape; os cinco nomes obrigatórios são `SID`, `SSID`, `HSID`, `APISID` e `SAPISID`. O ficheiro validado é guardado em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`, fora de `storage/state/`, com permissões locais restritas.
|
|
438
|
+
|
|
439
|
+
Em **Canais > Upload directo — conta e canal**, associe cada canal à conta Google que detém os cookies e o `sessionInfo token`, e preencha o `DELEGATED_SESSION_ID` individual desse canal. O upload lê os cookies e o token da conta associada e usa o ID delegado do canal como `pageId`/destino. No momento do upload, o Thunderbolt bloqueia a operação se não existir conta associada, ficheiro de cookies válido, `sessionInfo token`, `DELEGATED_SESSION_ID`, `INNERTUBE_API_KEY` ou vídeo elegível. O tamanho de chunk é validado e normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
|
|
430
440
|
|
|
431
441
|
## MCP e integrações externas
|
|
432
442
|
|
package/README.md
CHANGED
|
@@ -14,20 +14,29 @@ A primeira versão implementa a camada UI independente com:
|
|
|
14
14
|
| Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas e Upload |
|
|
15
15
|
| Blueprints | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
|
|
16
16
|
| Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
|
|
17
|
-
| Canais | Subabas de importação pública sem API Key,
|
|
17
|
+
| Canais | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente |
|
|
18
18
|
| Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; lotes; 51 rótulos de idioma; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
|
|
19
19
|
| Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
|
|
20
20
|
| Models AI | Menu expansível com Personagens e Redes Sociais reservados para desenvolvimento futuro |
|
|
21
21
|
| Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
|
|
22
22
|
| Edição | Menu expansível abaixo de Automação com Limpador de Metadados funcional, Cortes reservado e Editor Python inspirado no PYEdit para vídeos e scripts locais |
|
|
23
|
-
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo
|
|
23
|
+
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo via sessão Frontend API com cookies/sessionInfo por conta e DELEGATED_SESSION_ID por canal, TikTok, Instagram e Facebook Pages no front end |
|
|
24
24
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
25
|
-
| Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
25
|
+
| Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID e Client Secret por conta, cookies SID/SSID/HSID/APISID/SAPISID e sessionInfo por conta, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
26
26
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
27
27
|
|
|
28
|
+
## Upload directo — credenciais por conta e por canal
|
|
29
|
+
|
|
30
|
+
O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa dois níveis de configuração. Em **Configurações Técnicas > Upload directo — sessão YouTube Frontend API**, cada conta Google cadastrada possui o seu próprio ficheiro de cookies e o seu próprio `sessionInfo token`. O ficheiro pode ser um JSON exportado do navegador, uma lista JSON de cookies ou um ficheiro Netscape; o Thunderbolt valida e guarda apenas `SID`, `SSID`, `HSID`, `APISID` e `SAPISID` em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`.
|
|
31
|
+
|
|
32
|
+
Em **Canais > Upload directo — conta e canal**, cada canal é associado à conta Google que fornece os cookies e o `sessionInfo token`, e possui o seu próprio `DELEGATED_SESSION_ID`. O upload lê os cookies e o token da conta associada e envia o ID delegado individual do canal como `pageId`/`onBehalfOfUser`. O botão é bloqueado quando falta qualquer destes elementos. O `INNERTUBE_API_KEY` e o tamanho de chunk continuam nas configurações directas; o tamanho é normalizado para múltiplos de 262144 bytes.
|
|
33
|
+
|
|
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
|
+
|
|
36
|
+
|
|
28
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.
|
|
29
38
|
|
|
30
|
-
## Navegação da UI 0.2.
|
|
39
|
+
## Navegação da UI 0.2.42
|
|
31
40
|
|
|
32
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.
|
|
33
42
|
|
|
@@ -116,9 +125,9 @@ thunderbolt
|
|
|
116
125
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
117
126
|
|
|
118
127
|
```powershell
|
|
119
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
120
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
121
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
128
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.42 install
|
|
129
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.42 doctor
|
|
130
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.42
|
|
122
131
|
```
|
|
123
132
|
|
|
124
133
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -142,7 +151,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
|
|
|
142
151
|
|
|
143
152
|
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.
|
|
144
153
|
|
|
145
|
-
**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.
|
|
154
|
+
**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.42 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
146
155
|
|
|
147
156
|
## Editor Python baseado no PYEdit
|
|
148
157
|
|
package/app/main.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import re
|
|
5
5
|
from datetime import date, datetime
|
|
6
6
|
import sys
|
|
7
|
+
import uuid
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
import requests
|
|
@@ -33,6 +34,8 @@ from hermes_ui.music import list_music_files, materialize_suno_audio, request_su
|
|
|
33
34
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
34
35
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
35
36
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
37
|
+
from integrations.youtube_direct_credentials import COOKIE_KEYS as DIRECT_COOKIE_KEYS, direct_account_status, save_cookie_file
|
|
38
|
+
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
|
|
36
39
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
37
40
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
38
41
|
|
|
@@ -339,7 +342,12 @@ def render_channels():
|
|
|
339
342
|
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
340
343
|
settings = read_json("settings.json", {})
|
|
341
344
|
youtube = YouTubeAdapter(settings=settings)
|
|
342
|
-
|
|
345
|
+
youtube_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
346
|
+
youtube_account_ids = [""] + [str(account["id"]) for account in youtube_accounts]
|
|
347
|
+
youtube_account_labels = {"": "Sem conta Google associada"}
|
|
348
|
+
youtube_account_labels.update({str(account["id"]): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in youtube_accounts})
|
|
349
|
+
youtube_accounts_by_id = {str(account["id"]): account for account in youtube_accounts}
|
|
350
|
+
import_tab, batch_tab, manual_tab = st.tabs(["Importar do YouTube", "Canais em lote gmail", "Cadastro manual"])
|
|
343
351
|
|
|
344
352
|
with import_tab:
|
|
345
353
|
st.caption("A pesquisa pública funciona sem API Key. A Data API é opcional e fica disponível numa opção separada para métricas oficiais.")
|
|
@@ -384,6 +392,8 @@ def render_channels():
|
|
|
384
392
|
voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
|
|
385
393
|
current_voice = imported.get("default_voice") or imported.get("voice", "")
|
|
386
394
|
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")
|
|
395
|
+
imported_account_id = str(imported.get("google_account_id", ""))
|
|
396
|
+
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")
|
|
387
397
|
delegated_session_id = st.text_input("DELEGATED_SESSION_ID", value=imported.get("delegated_session_id", ""), type="password", key="yt_import_delegated_session_id")
|
|
388
398
|
automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
|
|
389
399
|
automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
|
|
@@ -410,6 +420,8 @@ def render_channels():
|
|
|
410
420
|
"default_voice": voice.strip(),
|
|
411
421
|
"voice": voice.strip(),
|
|
412
422
|
"delegated_session_id": delegated_session_id.strip(),
|
|
423
|
+
"google_account_id": google_account_id.strip(),
|
|
424
|
+
"google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
|
|
413
425
|
"automation_on": bool(automation_on),
|
|
414
426
|
"automation_time": automation_time.strip() if valid_hhmm(automation_time) else "00:00",
|
|
415
427
|
"subscriber_count": int(subscriber_count) or None,
|
|
@@ -423,6 +435,96 @@ def render_channels():
|
|
|
423
435
|
else:
|
|
424
436
|
st.info("Introduza um URL, handle ou ID e clique em Buscar no YouTube. Não é necessária API Key na opção pública.")
|
|
425
437
|
|
|
438
|
+
with batch_tab:
|
|
439
|
+
st.caption("Esta subaba usa a conta Google/YouTube seleccionada para listar os canais que ela gere. Não lê a caixa Gmail e não usa e-mails como pesquisa pública.")
|
|
440
|
+
accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
441
|
+
if not accounts:
|
|
442
|
+
st.info("Configure primeiro uma conta em Configurações > Configurações Técnicas > Contas Google/YouTube para canais em lote.")
|
|
443
|
+
else:
|
|
444
|
+
account_ids = [str(account["id"]) for account in accounts]
|
|
445
|
+
account_labels = {str(account["id"]): f"{account.get('email', 'Conta Google')} — {account.get('label', 'Canais YouTube')}" for account in accounts}
|
|
446
|
+
default_account_id = str(settings.get("youtube_batch_selected_account_id") or account_ids[0])
|
|
447
|
+
if default_account_id not in account_ids:
|
|
448
|
+
default_account_id = account_ids[0]
|
|
449
|
+
selected_account_id = st.selectbox("Conta Google/YouTube", account_ids, index=account_ids.index(default_account_id), format_func=lambda item: account_labels.get(item, item), key="batch_selected_account_id")
|
|
450
|
+
selected_account = next(account for account in accounts if str(account["id"]) == selected_account_id)
|
|
451
|
+
account_status = youtube_batch_account_status(selected_account, STORAGE)
|
|
452
|
+
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
453
|
+
auth_cols = st.columns([1, 2])
|
|
454
|
+
with auth_cols[0]:
|
|
455
|
+
if st.button("Autorizar conta Google", type="secondary", use_container_width=True, key="batch_authorize_account"):
|
|
456
|
+
result = authorize_youtube_batch_account(selected_account, STORAGE)
|
|
457
|
+
(st.success if result.ok else st.error)(result.message)
|
|
458
|
+
if result.ok:
|
|
459
|
+
st.rerun()
|
|
460
|
+
with auth_cols[1]:
|
|
461
|
+
st.caption("A autorização é feita no browser do sistema e fica guardada separadamente para esta conta.")
|
|
462
|
+
batch_key = f"youtube_batch_channels_{selected_account_id}"
|
|
463
|
+
if st.button("Listar canais desta conta", type="primary", use_container_width=True, key="batch_list_channels"):
|
|
464
|
+
result = list_youtube_batch_channels(selected_account, STORAGE)
|
|
465
|
+
st.session_state[batch_key] = result.data.get("channels", []) if result.ok else []
|
|
466
|
+
st.session_state[f"{batch_key}_message"] = result.message
|
|
467
|
+
st.session_state[f"{batch_key}_ok"] = result.ok
|
|
468
|
+
if st.session_state.get(f"{batch_key}_message"):
|
|
469
|
+
(st.success if st.session_state.get(f"{batch_key}_ok") else st.error)(st.session_state[f"{batch_key}_message"])
|
|
470
|
+
listed_channels = st.session_state.get(batch_key, [])
|
|
471
|
+
if listed_channels:
|
|
472
|
+
channel_by_id = {str(channel.get("youtube_channel_id")): channel for channel in listed_channels if channel.get("youtube_channel_id")}
|
|
473
|
+
channel_labels = {channel_id: f"{channel.get('name', channel_id)} — {channel.get('handle') or channel_id}" for channel_id, channel in channel_by_id.items()}
|
|
474
|
+
existing_channels = read_json("channels.json", [])
|
|
475
|
+
existing_ids = {str(channel.get("youtube_channel_id")) for channel in existing_channels if channel.get("youtube_channel_id")}
|
|
476
|
+
existing_count = sum(1 for channel_id in channel_by_id if channel_id in existing_ids)
|
|
477
|
+
st.caption(f"{len(listed_channels)} canal(is) listado(s); {existing_count} já cadastrado(s). A API devolveu a lista da conta autenticada, não uma caixa Gmail.")
|
|
478
|
+
blueprint_items = blueprint_catalog()
|
|
479
|
+
blueprint_ids = [item[0] for item in blueprint_items]
|
|
480
|
+
blueprint_labels = {item[0]: item[1] for item in blueprint_items}
|
|
481
|
+
voice_options = voice_catalog()
|
|
482
|
+
with st.form("batch_channel_import_form"):
|
|
483
|
+
selected_channel_ids = st.multiselect("Canais a cadastrar", list(channel_by_id), default=list(channel_by_id), format_func=lambda item: channel_labels.get(item, item), key="batch_channel_ids")
|
|
484
|
+
defaults_cols = st.columns(4)
|
|
485
|
+
with defaults_cols[0]:
|
|
486
|
+
batch_blueprint = st.selectbox("Blueprint padrão", blueprint_ids, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="batch_channel_blueprint")
|
|
487
|
+
with defaults_cols[1]:
|
|
488
|
+
batch_voice = st.selectbox("Voz padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="batch_channel_voice")
|
|
489
|
+
with defaults_cols[2]:
|
|
490
|
+
batch_language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], key="batch_channel_language")
|
|
491
|
+
with defaults_cols[3]:
|
|
492
|
+
batch_style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], key="batch_channel_style")
|
|
493
|
+
import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", use_container_width=True)
|
|
494
|
+
if import_selected:
|
|
495
|
+
created_names = []
|
|
496
|
+
skipped_names = []
|
|
497
|
+
failed_names = []
|
|
498
|
+
style_value = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(batch_style, batch_style)
|
|
499
|
+
for channel_id in selected_channel_ids:
|
|
500
|
+
data = channel_by_id[channel_id]
|
|
501
|
+
if channel_id in existing_ids:
|
|
502
|
+
skipped_names.append(data.get("name", channel_id))
|
|
503
|
+
continue
|
|
504
|
+
try:
|
|
505
|
+
create_channel(data.get("name", channel_id), data.get("url", ""), {
|
|
506
|
+
**data,
|
|
507
|
+
"youtube_channel_id": channel_id,
|
|
508
|
+
"google_account_id": str(selected_account.get("id", "")),
|
|
509
|
+
"google_account_email": str(selected_account.get("email", "")),
|
|
510
|
+
"blueprint_id": batch_blueprint.strip(),
|
|
511
|
+
"default_blueprint_id": batch_blueprint.strip(),
|
|
512
|
+
"default_voice": batch_voice.strip(),
|
|
513
|
+
"voice": batch_voice.strip(),
|
|
514
|
+
"language": batch_language,
|
|
515
|
+
"style_wide": style_value,
|
|
516
|
+
"last_youtube_sync": now(),
|
|
517
|
+
})
|
|
518
|
+
created_names.append(data.get("name", channel_id))
|
|
519
|
+
except Exception as exc:
|
|
520
|
+
failed_names.append(f"{data.get('name', channel_id)}: {exc}")
|
|
521
|
+
if created_names:
|
|
522
|
+
st.success(f"Canais cadastrados: {', '.join(created_names)}")
|
|
523
|
+
if skipped_names:
|
|
524
|
+
st.info(f"Já existentes e não duplicados: {', '.join(skipped_names)}")
|
|
525
|
+
if failed_names:
|
|
526
|
+
st.error(f"Falhas: {'; '.join(failed_names)}")
|
|
527
|
+
|
|
426
528
|
with manual_tab:
|
|
427
529
|
st.caption("Este fluxo não consulta o YouTube e não exige API Key. Preencha os dados que quiser e guarde o canal localmente.")
|
|
428
530
|
with st.form("channel_manual_form"):
|
|
@@ -438,6 +540,7 @@ def render_channels():
|
|
|
438
540
|
blueprint = st.selectbox("Blueprint padrão do canal", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
|
|
439
541
|
voice_options = voice_catalog()
|
|
440
542
|
voice = st.selectbox("Voz padrão do canal", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
|
|
543
|
+
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")
|
|
441
544
|
delegated_session_id = st.text_input("DELEGATED_SESSION_ID", type="password", key="manual_channel_delegated_session_id")
|
|
442
545
|
automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
|
|
443
546
|
automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="manual_channel_automation_time")
|
|
@@ -463,6 +566,8 @@ def render_channels():
|
|
|
463
566
|
"default_voice": voice.strip(),
|
|
464
567
|
"voice": voice.strip(),
|
|
465
568
|
"delegated_session_id": delegated_session_id.strip(),
|
|
569
|
+
"google_account_id": google_account_id.strip(),
|
|
570
|
+
"google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
|
|
466
571
|
"automation_on": bool(automation_on),
|
|
467
572
|
"automation_time": automation_time.strip(),
|
|
468
573
|
"thumbnail_url": thumbnail_url.strip(),
|
|
@@ -481,6 +586,20 @@ def render_channels():
|
|
|
481
586
|
return
|
|
482
587
|
for channel in channels:
|
|
483
588
|
with st.container(border=True):
|
|
589
|
+
channel_account_ids = list(youtube_account_ids)
|
|
590
|
+
current_channel_account_id = str(channel.get("google_account_id", ""))
|
|
591
|
+
if current_channel_account_id and current_channel_account_id not in channel_account_ids:
|
|
592
|
+
channel_account_ids.append(current_channel_account_id)
|
|
593
|
+
youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
|
|
594
|
+
with st.expander("Upload directo — conta e canal", expanded=False):
|
|
595
|
+
with st.form(f"channel_direct_credentials_{channel['id']}"):
|
|
596
|
+
channel_account_id = st.selectbox("Conta Google dos cookies/sessionInfo", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel['id']}")
|
|
597
|
+
channel_delegated_session_id = st.text_input("DELEGATED_SESSION_ID deste canal", value=str(channel.get("delegated_session_id", "")), type="password", key=f"channel_delegated_session_id_{channel['id']}")
|
|
598
|
+
save_channel_direct_credentials = st.form_submit_button("Guardar credenciais deste canal", type="primary")
|
|
599
|
+
if save_channel_direct_credentials:
|
|
600
|
+
update_channel(channel["id"], {"google_account_id": channel_account_id.strip(), "google_account_email": str(youtube_accounts_by_id.get(channel_account_id, {}).get("email", "")), "delegated_session_id": channel_delegated_session_id.strip()})
|
|
601
|
+
st.success("Conta Google e DELEGATED_SESSION_ID guardados neste canal.")
|
|
602
|
+
st.rerun()
|
|
484
603
|
cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
|
|
485
604
|
with cols[0]:
|
|
486
605
|
if channel.get("thumbnail_url"):
|
|
@@ -1145,6 +1264,7 @@ def render_upload_direct():
|
|
|
1145
1264
|
if not tasks:
|
|
1146
1265
|
st.info("Não há vídeos prontos para upload directo.")
|
|
1147
1266
|
return
|
|
1267
|
+
direct_accounts = {str(account.get("id")): account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")}
|
|
1148
1268
|
channel_map = {channel.get("id"): channel for channel in channels}
|
|
1149
1269
|
for task in tasks:
|
|
1150
1270
|
channel = channel_map.get(task.get("channel_id"), {})
|
|
@@ -1152,8 +1272,15 @@ def render_upload_direct():
|
|
|
1152
1272
|
with st.container(border=True):
|
|
1153
1273
|
st.write(f"**{task.get('topic', 'Sem tópico')}** — {task.get('channel_name', 'Canal')}")
|
|
1154
1274
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
1275
|
+
account = direct_accounts.get(str(channel.get("google_account_id", "")))
|
|
1155
1276
|
if not channel.get("delegated_session_id"):
|
|
1156
1277
|
st.warning("Este canal não tem DELEGATED_SESSION_ID configurado na aba Canais.")
|
|
1278
|
+
if not account:
|
|
1279
|
+
st.warning("Este canal não tem uma conta Google associada. Configure-a em Canais > Upload directo — conta e canal.")
|
|
1280
|
+
elif not direct_account_status(STORAGE, account)["ready"]:
|
|
1281
|
+
st.warning("A conta Google deste canal não tem cookies completos e sessionInfo token configurados em Configurações Técnicas.")
|
|
1282
|
+
elif channel.get("delegated_session_id"):
|
|
1283
|
+
st.caption(f"Credenciais directas: {account.get('email', 'conta Google')} · DELEGATED_SESSION_ID configurado")
|
|
1157
1284
|
direct_cols = st.columns([2.2, 1, 1])
|
|
1158
1285
|
with direct_cols[0]:
|
|
1159
1286
|
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
@@ -1163,12 +1290,19 @@ def render_upload_direct():
|
|
|
1163
1290
|
chunk_size = st.number_input("Chunk bytes", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)), key=f"direct_chunk_{task['id']}")
|
|
1164
1291
|
description = st.text_area("Descrição", value=task.get("description", ""), key=f"direct_description_{task['id']}", height=90)
|
|
1165
1292
|
if st.button("Enviar por Upload directo", type="primary", key=f"direct_upload_{task['id']}"):
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1293
|
+
if not account:
|
|
1294
|
+
st.error("Associe primeiro este canal a uma conta Google em Canais.")
|
|
1295
|
+
elif not channel.get("delegated_session_id"):
|
|
1296
|
+
st.error("Configure primeiro o DELEGATED_SESSION_ID individual deste canal em Canais.")
|
|
1297
|
+
elif not direct_account_status(STORAGE, account)["ready"]:
|
|
1298
|
+
st.error("Configure primeiro o ficheiro de cookies e o sessionInfo token desta conta em Configurações Técnicas.")
|
|
1299
|
+
else:
|
|
1300
|
+
result = YouTubeDirectUploader(settings, channel, account=account, storage_root=STORAGE).upload(video_path, title=title, description=description, visibility=privacy, chunk_size=int(chunk_size))
|
|
1301
|
+
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()}
|
|
1302
|
+
uploads = read_json("uploads.json", [])
|
|
1303
|
+
uploads.append(record)
|
|
1304
|
+
write_json("uploads.json", uploads)
|
|
1305
|
+
(st.success if result.ok else st.error)(result.message)
|
|
1172
1306
|
|
|
1173
1307
|
|
|
1174
1308
|
def render_upload():
|
|
@@ -1287,6 +1421,83 @@ def render_settings():
|
|
|
1287
1421
|
key=f"settings_{key}",
|
|
1288
1422
|
)
|
|
1289
1423
|
|
|
1424
|
+
st.subheader("Contas Google/YouTube — canais em lote")
|
|
1425
|
+
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.")
|
|
1426
|
+
batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
1427
|
+
for batch_account in batch_accounts:
|
|
1428
|
+
account_id = str(batch_account["id"])
|
|
1429
|
+
with st.container(border=True):
|
|
1430
|
+
with st.form(f"batch_account_form_{account_id}"):
|
|
1431
|
+
account_cols = st.columns(4)
|
|
1432
|
+
with account_cols[0]:
|
|
1433
|
+
account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
|
|
1434
|
+
with account_cols[1]:
|
|
1435
|
+
account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
|
|
1436
|
+
with account_cols[2]:
|
|
1437
|
+
account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
|
|
1438
|
+
with account_cols[3]:
|
|
1439
|
+
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}")
|
|
1440
|
+
save_account = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
|
|
1441
|
+
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
1442
|
+
status_cols = st.columns([2, 1, 1])
|
|
1443
|
+
with status_cols[0]:
|
|
1444
|
+
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
1445
|
+
with status_cols[1]:
|
|
1446
|
+
if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
|
|
1447
|
+
result = authorize_youtube_batch_account(batch_account, STORAGE)
|
|
1448
|
+
(st.success if result.ok else st.error)(result.message)
|
|
1449
|
+
if result.ok:
|
|
1450
|
+
st.rerun()
|
|
1451
|
+
with status_cols[2]:
|
|
1452
|
+
if st.button("Remover conta", key=f"batch_remove_settings_{account_id}", use_container_width=True):
|
|
1453
|
+
delete_youtube_batch_token(batch_account, STORAGE)
|
|
1454
|
+
remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
|
|
1455
|
+
settings["youtube_batch_accounts"] = remaining_accounts
|
|
1456
|
+
if settings.get("youtube_batch_selected_account_id") == account_id:
|
|
1457
|
+
settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
|
|
1458
|
+
write_json("settings.json", settings)
|
|
1459
|
+
st.rerun()
|
|
1460
|
+
if save_account:
|
|
1461
|
+
if "@" not in account_email.strip():
|
|
1462
|
+
st.error("Informe um e-mail Google válido.")
|
|
1463
|
+
elif not account_client_id.strip() or not account_client_secret.strip():
|
|
1464
|
+
st.error("Informe o Client ID e o Client Secret desta conta.")
|
|
1465
|
+
else:
|
|
1466
|
+
for existing in batch_accounts:
|
|
1467
|
+
if str(existing.get("id")) == account_id:
|
|
1468
|
+
credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
|
|
1469
|
+
if credentials_changed:
|
|
1470
|
+
delete_youtube_batch_token(existing, STORAGE)
|
|
1471
|
+
existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip()})
|
|
1472
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
1473
|
+
write_json("settings.json", settings)
|
|
1474
|
+
st.success("Conta Google/YouTube guardada.")
|
|
1475
|
+
st.rerun()
|
|
1476
|
+
with st.form("add_batch_account_form"):
|
|
1477
|
+
add_cols = st.columns(4)
|
|
1478
|
+
with add_cols[0]:
|
|
1479
|
+
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
1480
|
+
with add_cols[1]:
|
|
1481
|
+
new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
|
|
1482
|
+
with add_cols[2]:
|
|
1483
|
+
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
1484
|
+
with add_cols[3]:
|
|
1485
|
+
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
1486
|
+
add_account = st.form_submit_button("Adicionar conta Google/YouTube", use_container_width=True)
|
|
1487
|
+
if add_account:
|
|
1488
|
+
if "@" not in new_account_email.strip():
|
|
1489
|
+
st.error("Informe um e-mail Google válido.")
|
|
1490
|
+
elif not new_account_client_id.strip() or not new_account_client_secret.strip():
|
|
1491
|
+
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
1492
|
+
else:
|
|
1493
|
+
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()}
|
|
1494
|
+
batch_accounts.append(new_account)
|
|
1495
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
1496
|
+
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
1497
|
+
write_json("settings.json", settings)
|
|
1498
|
+
st.success(f"Conta {new_account['email']} adicionada.")
|
|
1499
|
+
st.rerun()
|
|
1500
|
+
|
|
1290
1501
|
with st.form("settings_form"):
|
|
1291
1502
|
st.subheader("Execução local")
|
|
1292
1503
|
port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
|
|
@@ -1325,18 +1536,46 @@ def render_settings():
|
|
|
1325
1536
|
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.")
|
|
1326
1537
|
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.")
|
|
1327
1538
|
|
|
1539
|
+
direct_account_session_fields = {}
|
|
1540
|
+
direct_cookie_uploads = {}
|
|
1328
1541
|
with st.expander("Upload directo — sessão YouTube Frontend API"):
|
|
1329
|
-
st.caption("
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1542
|
+
st.caption("Os cookies e o sessionInfo token são configurados por conta Google/YouTube. O DELEGATED_SESSION_ID é configurado separadamente em cada canal.")
|
|
1543
|
+
direct_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
1544
|
+
if not direct_accounts:
|
|
1545
|
+
st.info("Adicione primeiro uma conta em Contas Google/YouTube — canais em lote. Cada conta terá o seu ficheiro de cookies e sessionInfo token.")
|
|
1546
|
+
for direct_account in direct_accounts:
|
|
1547
|
+
direct_account_id = str(direct_account["id"])
|
|
1548
|
+
direct_status = direct_account_status(STORAGE, direct_account)
|
|
1549
|
+
with st.container(border=True):
|
|
1550
|
+
st.markdown(f"**{direct_account.get('label', 'Canais YouTube')}** — `{direct_account.get('email', 'sem e-mail')}`")
|
|
1551
|
+
direct_account_cols = st.columns([1.6, 1.6, 1])
|
|
1552
|
+
with direct_account_cols[0]:
|
|
1553
|
+
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}")
|
|
1554
|
+
with direct_account_cols[1]:
|
|
1555
|
+
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.")
|
|
1556
|
+
with direct_account_cols[2]:
|
|
1557
|
+
if direct_status["cookie_file_exists"] and not direct_status["missing_cookies"]:
|
|
1558
|
+
st.success("Cookies guardados")
|
|
1559
|
+
elif direct_status["cookie_file_exists"]:
|
|
1560
|
+
st.warning(f"Faltam: {', '.join(direct_status['missing_cookies'])}")
|
|
1561
|
+
else:
|
|
1562
|
+
st.warning("Sem ficheiro de cookies")
|
|
1563
|
+
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.")
|
|
1564
|
+
direct_account_session_fields[direct_account_id] = direct_session_info_value
|
|
1565
|
+
|
|
1566
|
+
with st.container(border=True):
|
|
1567
|
+
st.markdown("**Compatibilidade com instalação antiga — credenciais globais**")
|
|
1568
|
+
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.")
|
|
1569
|
+
direct_cookie_cols = st.columns(3)
|
|
1570
|
+
with direct_cookie_cols[0]:
|
|
1571
|
+
direct_cookie_sid = text_setting("SID", "direct_cookie_sid", secret=True)
|
|
1572
|
+
direct_cookie_ssid = text_setting("SSID", "direct_cookie_ssid", secret=True)
|
|
1573
|
+
with direct_cookie_cols[1]:
|
|
1574
|
+
direct_cookie_hsid = text_setting("HSID", "direct_cookie_hsid", secret=True)
|
|
1575
|
+
direct_cookie_apisid = text_setting("APISID", "direct_cookie_apisid", secret=True)
|
|
1576
|
+
with direct_cookie_cols[2]:
|
|
1577
|
+
direct_cookie_sapisid = text_setting("SAPISID", "direct_cookie_sapisid", secret=True)
|
|
1578
|
+
direct_session_info = text_setting("sessionInfo token global", "direct_session_info", secret=True)
|
|
1340
1579
|
direct_innertube_api_key = text_setting("INNERTUBE_API_KEY", "direct_innertube_api_key", secret=True)
|
|
1341
1580
|
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)))
|
|
1342
1581
|
|
|
@@ -1438,6 +1677,20 @@ def render_settings():
|
|
|
1438
1677
|
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
1439
1678
|
|
|
1440
1679
|
if st.form_submit_button("Guardar configurações do Thunderbolt", type="primary"):
|
|
1680
|
+
direct_cookie_error = ""
|
|
1681
|
+
configured_direct_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
1682
|
+
for direct_account in configured_direct_accounts:
|
|
1683
|
+
direct_account_id = str(direct_account["id"])
|
|
1684
|
+
direct_account["direct_session_info"] = str(direct_account_session_fields.get(direct_account_id, direct_account.get("direct_session_info", "")) or "").strip()
|
|
1685
|
+
uploaded_cookie_file = direct_cookie_uploads.get(direct_account_id)
|
|
1686
|
+
if uploaded_cookie_file is not None:
|
|
1687
|
+
try:
|
|
1688
|
+
save_cookie_file(STORAGE, direct_account, uploaded_cookie_file.getvalue(), uploaded_cookie_file.name)
|
|
1689
|
+
except ValueError as exc:
|
|
1690
|
+
direct_cookie_error = str(exc)
|
|
1691
|
+
settings["youtube_batch_accounts"] = configured_direct_accounts
|
|
1692
|
+
if direct_cookie_error:
|
|
1693
|
+
st.error(direct_cookie_error)
|
|
1441
1694
|
settings.update({
|
|
1442
1695
|
"port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
|
|
1443
1696
|
"youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
|
|
@@ -1462,14 +1715,17 @@ def render_settings():
|
|
|
1462
1715
|
"upload_post_auto_upload": upload_post_auto_upload,
|
|
1463
1716
|
})
|
|
1464
1717
|
write_json("settings.json", settings)
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1718
|
+
if direct_cookie_error:
|
|
1719
|
+
st.warning("As restantes configurações foram guardadas, mas o ficheiro de cookies não foi substituído. Corrija o ficheiro e tente novamente.")
|
|
1720
|
+
else:
|
|
1721
|
+
try:
|
|
1722
|
+
synced = sync_moneyprinter_config(settings, moneyprinter_path)
|
|
1723
|
+
if synced:
|
|
1724
|
+
st.success(f"Configurações guardadas e sincronizadas com {synced}")
|
|
1725
|
+
else:
|
|
1726
|
+
st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
|
|
1727
|
+
except Exception as exc:
|
|
1728
|
+
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
1473
1729
|
|
|
1474
1730
|
st.divider()
|
|
1475
1731
|
st.subheader("Teste de vozes")
|
package/hermes_ui/domain.py
CHANGED
|
@@ -22,6 +22,9 @@ def make_id(prefix: str) -> str:
|
|
|
22
22
|
def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
23
23
|
channel = {
|
|
24
24
|
"id": make_id("channel"),
|
|
25
|
+
"youtube_channel_id": "",
|
|
26
|
+
"google_account_id": "",
|
|
27
|
+
"google_account_email": "",
|
|
25
28
|
"name": name.strip(),
|
|
26
29
|
"url": url.strip(),
|
|
27
30
|
"handle": "",
|
package/hermes_ui/storage.py
CHANGED
|
@@ -83,6 +83,8 @@ DEFAULTS: dict[str, Any] = {
|
|
|
83
83
|
"youtube_api_key": "",
|
|
84
84
|
"youtube_client_id": "",
|
|
85
85
|
"youtube_client_secret": "",
|
|
86
|
+
"youtube_batch_accounts": [],
|
|
87
|
+
"youtube_batch_selected_account_id": "",
|
|
86
88
|
"kaggle_username": "",
|
|
87
89
|
"kaggle_api_key": "",
|
|
88
90
|
"kaggle_kernel_slug": "thunderbolt-niche-finder",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from integrations.platforms import IntegrationResult
|
|
12
|
+
|
|
13
|
+
BATCH_SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _text(value: Any) -> str:
|
|
17
|
+
return str(value or "").strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def account_key(account: dict[str, Any]) -> str:
|
|
21
|
+
raw = _text(account.get("id") or account.get("email"))
|
|
22
|
+
digest = hashlib.sha256(raw.lower().encode("utf-8")).hexdigest()[:16]
|
|
23
|
+
slug = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-")[:36] or "google-account"
|
|
24
|
+
return f"{slug}-{digest}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def token_path(storage_root: Path, account: dict[str, Any]) -> Path:
|
|
28
|
+
return Path(storage_root) / "state" / "youtube_batch_tokens" / f"{account_key(account)}.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _client_config(account: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"installed": {
|
|
34
|
+
"client_id": _text(account.get("client_id")),
|
|
35
|
+
"client_secret": _text(account.get("client_secret")),
|
|
36
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
37
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
38
|
+
"redirect_uris": ["http://localhost"],
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save_credentials(path: Path, credentials: Any) -> None:
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
temporary = path.with_name(f".{path.name}.{secrets.token_hex(6)}.tmp")
|
|
46
|
+
temporary.write_text(credentials.to_json(), encoding="utf-8")
|
|
47
|
+
try:
|
|
48
|
+
os.chmod(temporary, 0o600)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
temporary.replace(path)
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(path, 0o600)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_credentials(path: Path) -> Any | None:
|
|
59
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
from google.auth.transport.requests import Request
|
|
63
|
+
from google.oauth2.credentials import Credentials
|
|
64
|
+
except ImportError as exc:
|
|
65
|
+
raise RuntimeError("As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.") from exc
|
|
66
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
credentials = Credentials.from_authorized_user_info(raw, BATCH_SCOPES)
|
|
68
|
+
if credentials.expired and credentials.refresh_token:
|
|
69
|
+
credentials.refresh(Request())
|
|
70
|
+
_save_credentials(path, credentials)
|
|
71
|
+
return credentials
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def authorize_account(account: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
|
|
75
|
+
email = _text(account.get("email"))
|
|
76
|
+
if not email or "@" not in email:
|
|
77
|
+
return IntegrationResult(False, "Informe um e-mail Google válido para esta conta.", {"status": "email_not_configured"})
|
|
78
|
+
if not _text(account.get("client_id")) or not _text(account.get("client_secret")):
|
|
79
|
+
return IntegrationResult(False, "Preencha o OAuth Client ID e o OAuth Client Secret desta conta.", {"status": "oauth_not_configured", "email": email})
|
|
80
|
+
try:
|
|
81
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
82
|
+
except ImportError as exc:
|
|
83
|
+
return IntegrationResult(False, "As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.", {"status": "missing_dependencies", "error": str(exc)})
|
|
84
|
+
try:
|
|
85
|
+
flow = InstalledAppFlow.from_client_config(_client_config(account), BATCH_SCOPES)
|
|
86
|
+
credentials = flow.run_local_server(
|
|
87
|
+
host="localhost",
|
|
88
|
+
port=0,
|
|
89
|
+
open_browser=open_browser,
|
|
90
|
+
access_type="offline",
|
|
91
|
+
prompt="consent",
|
|
92
|
+
include_granted_scopes="true",
|
|
93
|
+
)
|
|
94
|
+
path = token_path(storage_root, account)
|
|
95
|
+
_save_credentials(path, credentials)
|
|
96
|
+
return IntegrationResult(True, f"Conta Google autorizada para listagem de canais: {email}.", {"status": "authorized", "email": email, "token_path": str(path)})
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
return IntegrationResult(False, f"A autorização da conta {email} falhou: {exc}", {"status": "authorization_failed", "email": email})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def delete_account_token(account: dict[str, Any], storage_root: Path) -> None:
|
|
102
|
+
path = token_path(storage_root, account)
|
|
103
|
+
try:
|
|
104
|
+
path.unlink(missing_ok=True)
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def account_status(account: dict[str, Any], storage_root: Path) -> IntegrationResult:
|
|
110
|
+
email = _text(account.get("email"))
|
|
111
|
+
if not email or not _text(account.get("client_id")) or not _text(account.get("client_secret")):
|
|
112
|
+
return IntegrationResult(False, "Conta incompleta: informe e-mail, Client ID e Client Secret.", {"status": "not_configured", "email": email})
|
|
113
|
+
path = token_path(storage_root, account)
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return IntegrationResult(False, "Conta configurada, mas ainda não autorizada.", {"status": "requires_authorization", "email": email})
|
|
116
|
+
try:
|
|
117
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
118
|
+
if isinstance(raw, dict) and (raw.get("refresh_token") or raw.get("access_token")):
|
|
119
|
+
return IntegrationResult(True, f"Conta autorizada: {email}", {"status": "ready", "email": email, "token_path": str(path), "refresh_on_use": bool(raw.get("refresh_token"))})
|
|
120
|
+
except (OSError, ValueError) as exc:
|
|
121
|
+
return IntegrationResult(False, f"Token da conta {email} inválido: {exc}", {"status": "invalid_token", "email": email})
|
|
122
|
+
return IntegrationResult(False, f"Token da conta {email} não está pronto.", {"status": "requires_authorization", "email": email})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _channel_record(item: dict[str, Any]) -> dict[str, Any]:
|
|
126
|
+
channel_id = _text(item.get("id"))
|
|
127
|
+
snippet = item.get("snippet") or {}
|
|
128
|
+
statistics = item.get("statistics") or {}
|
|
129
|
+
custom_url = _text(snippet.get("customUrl"))
|
|
130
|
+
url = f"https://www.youtube.com/{custom_url.lstrip('/')}" if custom_url else f"https://www.youtube.com/channel/{channel_id}"
|
|
131
|
+
thumbnails = snippet.get("thumbnails") or {}
|
|
132
|
+
thumbnail = (thumbnails.get("high") or thumbnails.get("medium") or thumbnails.get("default") or {}).get("url", "")
|
|
133
|
+
|
|
134
|
+
def integer(value: Any) -> int | None:
|
|
135
|
+
try:
|
|
136
|
+
return int(value) if value is not None else None
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"youtube_channel_id": channel_id,
|
|
142
|
+
"name": _text(snippet.get("title")) or channel_id,
|
|
143
|
+
"url": url,
|
|
144
|
+
"handle": custom_url,
|
|
145
|
+
"description": _text(snippet.get("description")),
|
|
146
|
+
"thumbnail_url": _text(thumbnail),
|
|
147
|
+
"subscriber_count": integer(statistics.get("subscriberCount")),
|
|
148
|
+
"video_count": integer(statistics.get("videoCount")),
|
|
149
|
+
"view_count": integer(statistics.get("viewCount")),
|
|
150
|
+
"metrics_source": "youtube_data_api_oauth_mine",
|
|
151
|
+
"published_at": _text(snippet.get("publishedAt")),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def list_my_channels(account: dict[str, Any], storage_root: Path) -> IntegrationResult:
|
|
156
|
+
status = account_status(account, storage_root)
|
|
157
|
+
if not status.ok:
|
|
158
|
+
return status
|
|
159
|
+
try:
|
|
160
|
+
credentials = _load_credentials(token_path(storage_root, account))
|
|
161
|
+
if credentials is None:
|
|
162
|
+
return IntegrationResult(False, "Autorize primeiro esta conta Google.", {"status": "requires_authorization"})
|
|
163
|
+
from googleapiclient.discovery import build
|
|
164
|
+
|
|
165
|
+
youtube = build("youtube", "v3", credentials=credentials, cache_discovery=False)
|
|
166
|
+
channels: list[dict[str, Any]] = []
|
|
167
|
+
page_token = None
|
|
168
|
+
while True:
|
|
169
|
+
kwargs: dict[str, Any] = {
|
|
170
|
+
"part": "snippet,contentDetails,statistics",
|
|
171
|
+
"mine": True,
|
|
172
|
+
"maxResults": 50,
|
|
173
|
+
}
|
|
174
|
+
if page_token:
|
|
175
|
+
kwargs["pageToken"] = page_token
|
|
176
|
+
response = youtube.channels().list(**kwargs).execute()
|
|
177
|
+
channels.extend(_channel_record(item) for item in response.get("items", []))
|
|
178
|
+
page_token = response.get("nextPageToken")
|
|
179
|
+
if not page_token:
|
|
180
|
+
break
|
|
181
|
+
return IntegrationResult(True, f"{len(channels)} canal(is) encontrado(s) na conta {account.get('email')}.", {"status": "listed", "email": account.get("email", ""), "channels": channels, "count": len(channels)})
|
|
182
|
+
except ImportError as exc:
|
|
183
|
+
return IntegrationResult(False, "A biblioteca Google API Client ainda não está instalada. Execute a instalação do Thunderbolt novamente.", {"status": "missing_dependencies", "error": str(exc)})
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
return IntegrationResult(False, f"Não foi possível listar os canais da conta {account.get('email', '')}: {exc}", {"status": "list_failed", "email": account.get("email", ""), "error": str(exc)})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
COOKIE_KEYS = ("SID", "SSID", "HSID", "APISID", "SAPISID")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def account_key(account: dict[str, Any]) -> str:
|
|
14
|
+
raw = str(account.get("id") or "").strip()
|
|
15
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-")
|
|
16
|
+
return safe[:80] or "google-account"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def account_directory(storage_root: Path, account: dict[str, Any]) -> Path:
|
|
20
|
+
return Path(storage_root) / "youtube_direct_accounts" / account_key(account)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cookie_file_path(storage_root: Path, account: dict[str, Any]) -> Path:
|
|
24
|
+
return account_directory(storage_root, account) / "cookies.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalise_pairs(value: Any) -> dict[str, str]:
|
|
28
|
+
pairs: dict[str, str] = {}
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
if isinstance(value.get("cookies"), list):
|
|
31
|
+
return _normalise_pairs(value["cookies"])
|
|
32
|
+
for key, item in value.items():
|
|
33
|
+
key_text = str(key).strip()
|
|
34
|
+
if key_text in COOKIE_KEYS:
|
|
35
|
+
pairs[key_text] = str(item or "").strip()
|
|
36
|
+
return pairs
|
|
37
|
+
if isinstance(value, list):
|
|
38
|
+
for item in value:
|
|
39
|
+
if isinstance(item, dict):
|
|
40
|
+
name = str(item.get("name") or item.get("key") or "").strip()
|
|
41
|
+
content = str(item.get("value") or "").strip()
|
|
42
|
+
if name in COOKIE_KEYS:
|
|
43
|
+
pairs[name] = content
|
|
44
|
+
return pairs
|
|
45
|
+
return pairs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_cookie_file(content: bytes, filename: str = "cookies.json") -> dict[str, str]:
|
|
49
|
+
text = content.decode("utf-8-sig", errors="replace").strip()
|
|
50
|
+
pairs: dict[str, str] = {}
|
|
51
|
+
try:
|
|
52
|
+
parsed = json.loads(text)
|
|
53
|
+
pairs.update(_normalise_pairs(parsed))
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
for line in text.splitlines():
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if not line or line.startswith("#"):
|
|
58
|
+
continue
|
|
59
|
+
fields = line.split("\t")
|
|
60
|
+
if len(fields) >= 7 and fields[5].strip() in COOKIE_KEYS:
|
|
61
|
+
pairs[fields[5].strip()] = fields[6].strip()
|
|
62
|
+
continue
|
|
63
|
+
if "=" in line:
|
|
64
|
+
name, value = line.split("=", 1)
|
|
65
|
+
name = name.strip()
|
|
66
|
+
if name in COOKIE_KEYS:
|
|
67
|
+
pairs[name] = value.strip().rstrip(";")
|
|
68
|
+
missing = [key for key in COOKIE_KEYS if not pairs.get(key)]
|
|
69
|
+
if missing:
|
|
70
|
+
raise ValueError(f"Faltam cookies obrigatórios no ficheiro {filename}: {', '.join(missing)}.")
|
|
71
|
+
return {key: pairs[key] for key in COOKIE_KEYS}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_cookie_file(storage_root: Path, account: dict[str, Any], content: bytes, filename: str = "cookies.json") -> Path:
|
|
75
|
+
cookies = parse_cookie_file(content, filename)
|
|
76
|
+
destination = cookie_file_path(storage_root, account)
|
|
77
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
temporary = destination.with_name(f".{destination.name}.{secrets.token_hex(6)}.tmp")
|
|
79
|
+
temporary.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
80
|
+
try:
|
|
81
|
+
os.chmod(temporary, 0o600)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
temporary.replace(destination)
|
|
85
|
+
try:
|
|
86
|
+
os.chmod(destination, 0o600)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
return destination
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_cookie_file(storage_root: Path, account: dict[str, Any]) -> dict[str, str]:
|
|
93
|
+
path = cookie_file_path(storage_root, account)
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return {}
|
|
96
|
+
try:
|
|
97
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, json.JSONDecodeError):
|
|
99
|
+
return {}
|
|
100
|
+
return _normalise_pairs(raw)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def direct_account_status(storage_root: Path, account: dict[str, Any]) -> dict[str, Any]:
|
|
104
|
+
cookies = load_cookie_file(storage_root, account)
|
|
105
|
+
missing = [key for key in COOKIE_KEYS if not cookies.get(key)]
|
|
106
|
+
session_info = str(account.get("direct_session_info") or "").strip()
|
|
107
|
+
return {
|
|
108
|
+
"cookie_file": str(cookie_file_path(storage_root, account)),
|
|
109
|
+
"cookie_file_exists": cookie_file_path(storage_root, account).exists(),
|
|
110
|
+
"missing_cookies": missing,
|
|
111
|
+
"has_session_info": bool(session_info),
|
|
112
|
+
"ready": not missing and bool(session_info),
|
|
113
|
+
}
|
|
@@ -8,6 +8,8 @@ import time
|
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any
|
|
11
|
+
|
|
12
|
+
from integrations.youtube_direct_credentials import COOKIE_KEYS, load_cookie_file
|
|
11
13
|
from urllib.parse import quote
|
|
12
14
|
|
|
13
15
|
import requests
|
|
@@ -15,7 +17,6 @@ import requests
|
|
|
15
17
|
|
|
16
18
|
CHUNK_GRANULARITY = 262144
|
|
17
19
|
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".webm", ".avi", ".mpeg", ".mpg", ".flv", ".wmv", ".3gpp"}
|
|
18
|
-
COOKIE_KEYS = ("SID", "SSID", "HSID", "APISID", "SAPISID")
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
@dataclass
|
|
@@ -25,10 +26,20 @@ class DirectUploadResult:
|
|
|
25
26
|
data: dict[str, Any]
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
def _cookie_settings(settings: dict[str, Any]) -> dict[str, str]:
|
|
29
|
+
def _cookie_settings(settings: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> dict[str, str]:
|
|
30
|
+
if account is not None and storage_root is not None:
|
|
31
|
+
account_cookies = load_cookie_file(storage_root, account)
|
|
32
|
+
if account_cookies:
|
|
33
|
+
return {key: str(account_cookies.get(key, "") or "").strip() for key in COOKIE_KEYS}
|
|
29
34
|
return {key: str(settings.get(f"direct_cookie_{key.lower()}", "") or "").strip() for key in COOKIE_KEYS}
|
|
30
35
|
|
|
31
36
|
|
|
37
|
+
def _session_info(settings: dict[str, Any], account: dict[str, Any] | None = None) -> str:
|
|
38
|
+
if account is not None:
|
|
39
|
+
return str(account.get("direct_session_info", "") or "").strip()
|
|
40
|
+
return str(settings.get("direct_session_info", "") or "").strip()
|
|
41
|
+
|
|
42
|
+
|
|
32
43
|
def _cookie_header(cookies: dict[str, str]) -> str:
|
|
33
44
|
return ";".join(["CONSENT=YES+cb"] + [f"{key}={value}" for key, value in cookies.items() if value])
|
|
34
45
|
|
|
@@ -51,7 +62,7 @@ def _header(response: requests.Response, name: str) -> str:
|
|
|
51
62
|
return ""
|
|
52
63
|
|
|
53
64
|
|
|
54
|
-
def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], settings: dict[str, Any]) -> str | None:
|
|
65
|
+
def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], settings: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> str | None:
|
|
55
66
|
path = Path(video_path)
|
|
56
67
|
if not path.exists() or not path.is_file():
|
|
57
68
|
return "Ficheiro de vídeo não encontrado."
|
|
@@ -59,10 +70,10 @@ def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], sett
|
|
|
59
70
|
return "Formato de vídeo não suportado pelo upload directo."
|
|
60
71
|
if path.stat().st_size <= 0:
|
|
61
72
|
return "O ficheiro de vídeo está vazio."
|
|
62
|
-
missing_cookies = [key for key, value in _cookie_settings(settings).items() if not value]
|
|
73
|
+
missing_cookies = [key for key, value in _cookie_settings(settings, account, storage_root).items() if not value]
|
|
63
74
|
if missing_cookies:
|
|
64
75
|
return f"Faltam cookies de sessão para upload directo: {', '.join(missing_cookies)}."
|
|
65
|
-
if not
|
|
76
|
+
if not _session_info(settings, account):
|
|
66
77
|
return "Configure o token sessionInfo do upload directo."
|
|
67
78
|
if not str(settings.get("direct_innertube_api_key", "") or "").strip():
|
|
68
79
|
return "Configure o INNERTUBE_API_KEY do upload directo."
|
|
@@ -72,11 +83,14 @@ def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], sett
|
|
|
72
83
|
|
|
73
84
|
|
|
74
85
|
class YouTubeDirectUploader:
|
|
75
|
-
def __init__(self, settings: dict[str, Any], channel: dict[str, Any], *, session: requests.Session | None = None):
|
|
86
|
+
def __init__(self, settings: dict[str, Any], channel: dict[str, Any], *, account: dict[str, Any] | None = None, storage_root: Path | None = None, session: requests.Session | None = None):
|
|
76
87
|
self.settings = settings
|
|
77
88
|
self.channel = channel
|
|
89
|
+
self.account = account
|
|
90
|
+
self.storage_root = storage_root
|
|
78
91
|
self.session = session or requests.Session()
|
|
79
|
-
self.cookies = _cookie_settings(settings)
|
|
92
|
+
self.cookies = _cookie_settings(settings, account, storage_root)
|
|
93
|
+
self.session_info = _session_info(settings, account)
|
|
80
94
|
self.cookie_header = _cookie_header(self.cookies)
|
|
81
95
|
self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36"
|
|
82
96
|
self.inner_tube = _innertube_id()
|
|
@@ -124,7 +138,7 @@ class YouTubeDirectUploader:
|
|
|
124
138
|
"botguardClientResponse": f"${hashlib.sha1(os.urandom(16)).hexdigest()}",
|
|
125
139
|
"context": {
|
|
126
140
|
"client": {"clientName": 62, "clientVersion": "1.20210806.02.00", "hl": "pt-BR", "gl": "BR", "experimentsToken": "", "utcOffsetMinutes": 0},
|
|
127
|
-
"request": {"sessionInfo": {"token":
|
|
141
|
+
"request": {"sessionInfo": {"token": self.session_info}},
|
|
128
142
|
"user": {"onBehalfOfUser": str(self.channel.get("delegated_session_id", ""))},
|
|
129
143
|
},
|
|
130
144
|
}
|
|
@@ -157,13 +171,13 @@ class YouTubeDirectUploader:
|
|
|
157
171
|
|
|
158
172
|
def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "private", chunk_size: int = CHUNK_GRANULARITY) -> DirectUploadResult:
|
|
159
173
|
path = Path(video_path)
|
|
160
|
-
validation_error = validate_direct_upload(path, self.channel, self.settings)
|
|
174
|
+
validation_error = validate_direct_upload(path, self.channel, self.settings, self.account, self.storage_root)
|
|
161
175
|
if validation_error:
|
|
162
176
|
return DirectUploadResult(False, validation_error, {"mechanism": "youtube-frontend-direct"})
|
|
163
177
|
try:
|
|
164
178
|
self.describe_file(path)
|
|
165
179
|
self.create_video(title, description, visibility)
|
|
166
180
|
self.upload_chunks(path, chunk_size)
|
|
167
|
-
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", "")})
|
|
181
|
+
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", "")})
|
|
168
182
|
except (requests.RequestException, OSError, ValueError, RuntimeError) as exc:
|
|
169
183
|
return DirectUploadResult(False, f"Upload directo falhou: {exc}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id})
|
package/package.json
CHANGED