@danhachuel/thunderbolt 0.2.22 → 0.2.24
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 +12 -12
- package/README.md +10 -10
- package/app/main.py +71 -11
- package/hermes_ui/domain.py +12 -0
- package/integrations/platforms.py +153 -28
- 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.24
|
|
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.24 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.24 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.
|
|
@@ -339,20 +339,20 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
|
|
339
339
|
|
|
340
340
|
## 8. Configuração inicial da UI
|
|
341
341
|
|
|
342
|
-
Na primeira execução, abra **Configurações** e reveja
|
|
342
|
+
Na primeira execução, abra **Configurações** e reveja. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
|
|
343
343
|
|
|
344
344
|
| Configuração | Finalidade |
|
|
345
345
|
|---|---|
|
|
346
346
|
| Porta Streamlit | Definir a porta local da UI |
|
|
347
347
|
| Pasta MoneyPrinterTurbo | Indicar o clone local que será sincronizado com `config.toml` |
|
|
348
|
-
| YouTube Data API Key | Permitir
|
|
349
|
-
| YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para operações autenticadas |
|
|
350
|
-
| YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é
|
|
348
|
+
| YouTube Data API Key (opcional) | Permitir o método oficial Data API para métricas públicas; não é necessária para a página pública nem para OAuth |
|
|
349
|
+
| YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para autorizar operações autenticadas, como upload |
|
|
350
|
+
| YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é uma Data API Key nem um token de acesso |
|
|
351
351
|
| YouTube upload principal | Lógica do `youtube-automation-agent` adaptada e executada dentro do Thunderbolt |
|
|
352
352
|
| OAuth directo de redundância | Caminho alternativo accionado automaticamente se o agente falhar |
|
|
353
353
|
| TikTok Client ID/Secret | Credenciais da aplicação; Redirect URI, scopes e autorização ficam no TikTok for Developers Playground |
|
|
354
354
|
|
|
355
|
-
As
|
|
355
|
+
As credenciais devem ser inseridas apenas na configuração local. A Data API Key, o OAuth Client ID e o OAuth Client Secret são valores diferentes; Client ID + Secret não geram uma API Key nem um token OAuth até a conta ser autorizada. Não coloque nenhum deles no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
|
|
356
356
|
|
|
357
357
|
## 9. Testar as áreas principais
|
|
358
358
|
|
|
@@ -361,14 +361,14 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
361
361
|
1. **Dashboard:** confirme que a UI abre e mostra o estado local.
|
|
362
362
|
2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
|
|
363
363
|
3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
|
|
364
|
-
4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** ou a Data API opcional; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
|
|
364
|
+
4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** com um URL `/channel/UC...`, um handle ou uma subpágina `/videos`; o parser resolve o ID, consulta a página pública e tenta o RSS quando necessário. Confirme que o resultado abre o formulário de revisão sem Data API Key. Se o canal não existir ou não fornecer metadados, confirme a mensagem clara e que o formulário de uma pesquisa anterior desaparece. A Data API é opcional e fica separada; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
|
|
365
365
|
5. **Novo vídeo:** teste primeiro o modo **Canal específico** e depois os modos de lote.
|
|
366
366
|
6. **Novo vídeo > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba.
|
|
367
|
-
7. **Upload:** 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 é exclusivamente para consultas públicas.
|
|
367
|
+
7. **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.
|
|
368
368
|
8. **Limpador de metadado:** 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 da aba Novo vídeo não são aceites nesta área.
|
|
369
369
|
9. **Configurações:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
370
370
|
10. **Novo vídeo:** 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`.
|
|
371
|
-
11. **Automação:**
|
|
371
|
+
11. **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. A UI não inicia jobs em segundo plano nesta entrega.
|
|
372
372
|
12. **Teste de vozes:** em Configurações, teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
|
|
373
373
|
13. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
|
|
374
374
|
14. **MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é apenas uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
|
|
@@ -378,7 +378,7 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
378
378
|
|
|
379
379
|
O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`, o selector `Estilo IA` apresenta os 12 estilos disponíveis. Ao seleccionar `Apenas Música`, a UI obriga a escolher uma música existente, carregar um ficheiro ou solicitar uma música a um endpoint Suno configurado. O vídeo recebe `background_mode=none`, sem fundo Pexels/Pixabay ou IA.
|
|
380
380
|
|
|
381
|
-
A aba **Automação** lista os canais e vídeos, guarda **Automação ON** e valida horários diários no formato `HH:MM`. É apenas uma camada de UI nesta versão, sem execução de worker.
|
|
381
|
+
A aba **Automação** lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais** e são copiados para novas tarefas. É apenas uma camada de UI nesta versão, sem execução de worker.
|
|
382
382
|
|
|
383
383
|
A área **Teste de vozes**, dentro de Configurações, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`.
|
|
384
384
|
|
package/README.md
CHANGED
|
@@ -16,11 +16,11 @@ A primeira versão implementa a camada UI independente com:
|
|
|
16
16
|
| Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
|
|
17
17
|
| Canais | Subabas de importação pública sem API Key, Data API opcional e cadastro manual independente |
|
|
18
18
|
| Novo vídeo | 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 |
|
|
19
|
-
| Automação | Lista de vídeos e canais, Automação ON e horário diário HH:MM; UI configurável sem worker em segundo plano |
|
|
19
|
+
| Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON e horário diário HH:MM; UI configurável sem worker em segundo plano |
|
|
20
20
|
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, TikTok, Instagram e Facebook Pages no front end |
|
|
21
21
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
22
22
|
| Limpador de metadado | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
|
|
23
|
-
| Configurações | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, YouTube, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
23
|
+
| Configurações | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
24
24
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
25
25
|
|
|
26
26
|
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.
|
|
@@ -104,9 +104,9 @@ thunderbolt
|
|
|
104
104
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
105
105
|
|
|
106
106
|
```powershell
|
|
107
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
108
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
107
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.24 install
|
|
108
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.24 doctor
|
|
109
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.24
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -167,15 +167,15 @@ A descrição segue a composição do workflow de referência: preview, secção
|
|
|
167
167
|
|
|
168
168
|
## Canais YouTube
|
|
169
169
|
|
|
170
|
-
A aba **Canais** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando
|
|
170
|
+
A aba **Canais** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando uma **YouTube Data API Key própria** estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`; essa chave é distinta do OAuth Client ID e Client Secret.
|
|
171
171
|
|
|
172
|
-
Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo, Blueprint padrão, voz padrão, `DELEGATED_SESSION_ID` e configuração de Automação. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar. Cada cartão de canal tem **Activo** e, logo abaixo, **Apagar canal**, com confirmação; tarefas e artefactos não são apagados.
|
|
172
|
+
Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo, Blueprint padrão, voz padrão, `DELEGATED_SESSION_ID` e configuração de Automação. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar. A resolução pública aceita URLs `/channel/UC...`, handles e subpáginas; quando existe um ID mas o HTML não traz todos os dados, tenta o feed RSS público. Se o YouTube responder que o canal não existe ou não fornecer metadados, a UI mostra uma mensagem clara e não mantém o formulário de uma pesquisa anterior. Cada cartão de canal tem **Activo** e, logo abaixo, **Apagar canal**, com confirmação; tarefas e artefactos não são apagados.
|
|
173
173
|
|
|
174
174
|
## Upload YouTube
|
|
175
175
|
|
|
176
176
|
O Upload usa como caminho **principal** a lógica do `PublishingSchedulingAgent` do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent), adaptada para Python e executada dentro do processo Streamlit do Thunderbolt. Não é necessário instalar ou iniciar um segundo servidor Node. O fluxo valida o MP4 real, constrói `snippet/status`, faz upload resumível e tenta enviar thumbnail e legendas.
|
|
177
177
|
|
|
178
|
-
Na aba **Upload**, configure primeiro o **YouTube OAuth Client ID** e o **YouTube OAuth Client Secret** em **Configurações**. Em seguida, use **Autorizar agente YouTube**. O navegador local abrirá a autorização Google e o token será guardado apenas em `storage/state/youtube_agent_tokens.json`. Se a publicação principal falhar, o Thunderbolt tenta automaticamente o **OAuth directo de redundância**, usando o token local compatível; a Data API Key nunca é usada para publicar.
|
|
178
|
+
Na aba **Upload**, configure primeiro apenas o **YouTube OAuth Client ID** e o **YouTube OAuth Client Secret** em **Configurações**. Em seguida, use **Autorizar agente YouTube**. O navegador local abrirá a autorização Google e o token será guardado apenas em `storage/state/youtube_agent_tokens.json`. Se a publicação principal falhar, o Thunderbolt tenta automaticamente o **OAuth directo de redundância**, usando o token local compatível; a Data API Key nunca é usada para autorizar ou publicar.
|
|
179
179
|
|
|
180
180
|
Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separada para o caminho de redundância. Os resultados guardam no histórico local qual mecanismo foi utilizado e as tentativas realizadas, sem guardar segredos.
|
|
181
181
|
|
|
@@ -185,7 +185,7 @@ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https:/
|
|
|
185
185
|
|
|
186
186
|
Em **Novo vídeo**, o estilo visual `Pexels/Pixabay` é o modo de materiais, `full_ia` abre o selector **Estilo IA** com os 12 estilos solicitados e **Apenas Música** exige um áudio local, um upload musical ou um pedido ao endpoint Suno configurado. Neste último modo, a task fica com `background_mode=none`: não são gerados fundos Pexels/Pixabay nem fundos IA.
|
|
187
187
|
|
|
188
|
-
O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. A aba **Automação** guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados; por definição, esta entrega não executa workers de fundo.
|
|
188
|
+
O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados; por definição, esta entrega não executa workers de fundo.
|
|
189
189
|
|
|
190
190
|
## Teste de vozes
|
|
191
191
|
|
|
@@ -203,7 +203,7 @@ A mesma aba permite **Guardar skill localmente**, copiando `moneyprinterturbo-vi
|
|
|
203
203
|
|
|
204
204
|
A subaba **Vídeos**, dentro de **Novo vídeo**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
|
|
205
205
|
|
|
206
|
-
A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret.
|
|
206
|
+
A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret para TikTok. Para YouTube, o bloco principal pede o OAuth Client ID e Client Secret; a Data API Key, se usada, fica numa área opcional separada. Client ID + Client Secret não formam uma Data API Key nem um token OAuth.
|
|
207
207
|
Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
|
|
208
208
|
|
|
209
209
|
## Segurança
|
package/app/main.py
CHANGED
|
@@ -16,7 +16,7 @@ try:
|
|
|
16
16
|
except (OSError, json.JSONDecodeError):
|
|
17
17
|
APP_VERSION = ""
|
|
18
18
|
|
|
19
|
-
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, transition_task, update_channel
|
|
19
|
+
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel
|
|
20
20
|
from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
21
21
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
22
22
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
@@ -179,6 +179,20 @@ def voice_catalog(current: str = "") -> list[str]:
|
|
|
179
179
|
return voices
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
def channel_default_options(channel: dict) -> tuple[list[str], dict[str, str], str, list[str], str]:
|
|
183
|
+
"""Return synchronised Blueprint and voice options for a channel editor."""
|
|
184
|
+
blueprint_items = blueprint_catalog()
|
|
185
|
+
blueprint_ids = [item[0] for item in blueprint_items]
|
|
186
|
+
blueprint_labels = {item[0]: item[1] for item in blueprint_items}
|
|
187
|
+
current_blueprint = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "")
|
|
188
|
+
if current_blueprint and current_blueprint not in blueprint_ids:
|
|
189
|
+
blueprint_ids.append(current_blueprint)
|
|
190
|
+
blueprint_labels[current_blueprint] = current_blueprint
|
|
191
|
+
current_voice = str(channel.get("default_voice") or channel.get("voice") or "")
|
|
192
|
+
voice_options = voice_catalog(current_voice)
|
|
193
|
+
return blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice
|
|
194
|
+
|
|
195
|
+
|
|
182
196
|
def render_dashboard():
|
|
183
197
|
st.title("Thunderbolt")
|
|
184
198
|
st.caption("Interface local para operação e automação de conteúdo faceless")
|
|
@@ -335,7 +349,7 @@ def render_channels():
|
|
|
335
349
|
st.rerun()
|
|
336
350
|
if st.session_state.get("yt_message"):
|
|
337
351
|
(st.success if st.session_state.get("yt_ok") else st.warning)(st.session_state["yt_message"])
|
|
338
|
-
imported = st.session_state.get("yt_import", {})
|
|
352
|
+
imported = st.session_state.get("yt_import", {}) if st.session_state.get("yt_ok") else {}
|
|
339
353
|
blueprint_items = blueprint_catalog()
|
|
340
354
|
blueprint_ids = [item[0] for item in blueprint_items]
|
|
341
355
|
blueprint_labels = {item[0]: item[1] for item in blueprint_items}
|
|
@@ -488,6 +502,29 @@ def render_channels():
|
|
|
488
502
|
if st.button("Cancelar", key=f"cancel_delete_{channel['id']}", use_container_width=True):
|
|
489
503
|
st.session_state.pop(delete_key, None)
|
|
490
504
|
st.rerun()
|
|
505
|
+
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
506
|
+
with st.expander("Definir Blueprint e voz padrão", expanded=False):
|
|
507
|
+
editor_cols = st.columns(2)
|
|
508
|
+
with editor_cols[0]:
|
|
509
|
+
channel_blueprint = st.selectbox(
|
|
510
|
+
"Blueprint padrão",
|
|
511
|
+
blueprint_ids,
|
|
512
|
+
index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
|
|
513
|
+
format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
|
|
514
|
+
key=f"channel_default_blueprint_{channel['id']}",
|
|
515
|
+
)
|
|
516
|
+
with editor_cols[1]:
|
|
517
|
+
channel_voice = st.selectbox(
|
|
518
|
+
"Voz padrão",
|
|
519
|
+
voice_options,
|
|
520
|
+
index=voice_options.index(current_voice) if current_voice in voice_options else 0,
|
|
521
|
+
format_func=lambda item: item or "Sem voz padrão",
|
|
522
|
+
key=f"channel_default_voice_{channel['id']}",
|
|
523
|
+
)
|
|
524
|
+
if st.button("Guardar Blueprint e voz", key=f"save_channel_defaults_{channel['id']}", type="primary"):
|
|
525
|
+
set_channel_defaults(channel["id"], channel_blueprint, channel_voice)
|
|
526
|
+
st.success("Blueprint padrão e voz padrão guardados para este canal.")
|
|
527
|
+
st.rerun()
|
|
491
528
|
|
|
492
529
|
|
|
493
530
|
def render_new_video():
|
|
@@ -624,7 +661,25 @@ def render_automation():
|
|
|
624
661
|
with cols[1]:
|
|
625
662
|
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
626
663
|
st.caption(channel.get("handle") or channel.get("url") or "sem URL")
|
|
627
|
-
st.caption(f"Blueprint: {channel.get('default_blueprint_id') or channel.get('blueprint_id') or '—'} · Voz: {channel.get('default_voice') or channel.get('voice') or '—'}")
|
|
664
|
+
st.caption(f"Blueprint actual: {channel.get('default_blueprint_id') or channel.get('blueprint_id') or '—'} · Voz actual: {channel.get('default_voice') or channel.get('voice') or '—'}")
|
|
665
|
+
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
666
|
+
default_cols = st.columns(2)
|
|
667
|
+
with default_cols[0]:
|
|
668
|
+
automation_blueprint = st.selectbox(
|
|
669
|
+
"Blueprint padrão",
|
|
670
|
+
blueprint_ids,
|
|
671
|
+
index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
|
|
672
|
+
format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
|
|
673
|
+
key=f"automation_blueprint_{channel_id}",
|
|
674
|
+
)
|
|
675
|
+
with default_cols[1]:
|
|
676
|
+
automation_voice = st.selectbox(
|
|
677
|
+
"Voz padrão",
|
|
678
|
+
voice_options,
|
|
679
|
+
index=voice_options.index(current_voice) if current_voice in voice_options else 0,
|
|
680
|
+
format_func=lambda item: item or "Sem voz padrão",
|
|
681
|
+
key=f"automation_voice_{channel_id}",
|
|
682
|
+
)
|
|
628
683
|
with cols[2]:
|
|
629
684
|
enabled = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
|
|
630
685
|
with cols[3]:
|
|
@@ -634,7 +689,11 @@ def render_automation():
|
|
|
634
689
|
if not valid_hhmm(schedule_time):
|
|
635
690
|
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
636
691
|
else:
|
|
637
|
-
update_channel(channel_id, {
|
|
692
|
+
update_channel(channel_id, {
|
|
693
|
+
"automation_on": bool(enabled),
|
|
694
|
+
"automation_time": schedule_time.strip(),
|
|
695
|
+
})
|
|
696
|
+
set_channel_defaults(channel_id, automation_blueprint, automation_voice)
|
|
638
697
|
st.success("Agendamento guardado.")
|
|
639
698
|
st.rerun()
|
|
640
699
|
|
|
@@ -818,15 +877,16 @@ def render_settings():
|
|
|
818
877
|
st.subheader("Execução local")
|
|
819
878
|
port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
|
|
820
879
|
moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
|
|
821
|
-
st.markdown("**YouTube —
|
|
822
|
-
st.caption("
|
|
823
|
-
youtube_cols = st.columns(
|
|
880
|
+
st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
|
|
881
|
+
st.caption("Para autorizar uploads, preencha apenas o YouTube OAuth Client ID e o YouTube OAuth Client Secret. Depois, autorize o agente na aba Upload. Estes dados identificam a aplicação OAuth; não são uma Data API Key nem um token de acesso.")
|
|
882
|
+
youtube_cols = st.columns(2)
|
|
824
883
|
with youtube_cols[0]:
|
|
825
|
-
|
|
884
|
+
youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud. É usado para iniciar a autorização da conta YouTube.")
|
|
826
885
|
with youtube_cols[1]:
|
|
827
|
-
|
|
828
|
-
with
|
|
829
|
-
|
|
886
|
+
youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.")
|
|
887
|
+
with st.expander("Consulta oficial de métricas — opcional"):
|
|
888
|
+
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.")
|
|
889
|
+
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.")
|
|
830
890
|
|
|
831
891
|
with st.expander("Upload directo — sessão YouTube Frontend API"):
|
|
832
892
|
st.caption("Campos usados apenas pelo método Upload directo. Não são necessários para o youtube-automation-agent nem para a Data API pública.")
|
package/hermes_ui/domain.py
CHANGED
|
@@ -66,6 +66,18 @@ def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] |
|
|
|
66
66
|
return None
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def set_channel_defaults(channel_id: str, blueprint_id: str = "", voice: str = "") -> dict[str, Any] | None:
|
|
70
|
+
"""Persist the canonical and legacy aliases for a channel's generation defaults."""
|
|
71
|
+
blueprint = str(blueprint_id or "").strip()
|
|
72
|
+
selected_voice = str(voice or "").strip()
|
|
73
|
+
return update_channel(channel_id, {
|
|
74
|
+
"blueprint_id": blueprint,
|
|
75
|
+
"default_blueprint_id": blueprint,
|
|
76
|
+
"voice": selected_voice,
|
|
77
|
+
"default_voice": selected_voice,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
|
|
69
81
|
def delete_channel(channel_id: str) -> dict[str, Any] | None:
|
|
70
82
|
"""Remove apenas o cadastro do canal, preservando tarefas e artefactos."""
|
|
71
83
|
channels = read_json("channels.json", [])
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import xml.etree.ElementTree as ET
|
|
7
|
+
from html import unescape
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
@@ -121,6 +122,69 @@ def _meta_content(document: str, *names: str) -> str:
|
|
|
121
122
|
return ""
|
|
122
123
|
|
|
123
124
|
|
|
125
|
+
def _channel_id_from_source(source: str) -> str:
|
|
126
|
+
"""Extract a channel ID from a direct channel URL or a raw UC... value."""
|
|
127
|
+
value = (source or "").strip()
|
|
128
|
+
match = re.search(r"(?<![A-Za-z0-9_-])(UC[A-Za-z0-9_-]{3,})(?![A-Za-z0-9_-])", value)
|
|
129
|
+
return match.group(1) if match else ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _canonical_link(document: str) -> str:
|
|
133
|
+
patterns = [
|
|
134
|
+
r'<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\']',
|
|
135
|
+
r'<link[^>]+href=["\']([^"\']+)["\'][^>]+rel=["\']canonical["\']',
|
|
136
|
+
]
|
|
137
|
+
for pattern in patterns:
|
|
138
|
+
match = re.search(pattern, document, flags=re.IGNORECASE)
|
|
139
|
+
if match:
|
|
140
|
+
return unescape(match.group(1)).strip()
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _jsonld_channel_data(document: str) -> dict[str, Any]:
|
|
145
|
+
"""Read channel-like fields from JSON-LD without requiring a fixed YouTube renderer."""
|
|
146
|
+
for raw in re.findall(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', document, flags=re.IGNORECASE | re.DOTALL):
|
|
147
|
+
try:
|
|
148
|
+
payload = json.loads(unescape(raw.strip()))
|
|
149
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
150
|
+
continue
|
|
151
|
+
candidates = payload if isinstance(payload, list) else [payload]
|
|
152
|
+
for candidate in candidates:
|
|
153
|
+
if not isinstance(candidate, dict):
|
|
154
|
+
continue
|
|
155
|
+
candidate_type = candidate.get("@type", "")
|
|
156
|
+
if candidate_type in {"Person", "Organization", "Brand", "ProfilePage"} or any(key in candidate for key in ("channelId", "identifier", "sameAs")):
|
|
157
|
+
image = candidate.get("image", "")
|
|
158
|
+
if isinstance(image, dict):
|
|
159
|
+
image = image.get("url", "")
|
|
160
|
+
identifier = candidate.get("channelId") or candidate.get("identifier", "")
|
|
161
|
+
if isinstance(identifier, dict):
|
|
162
|
+
identifier = identifier.get("value", "")
|
|
163
|
+
same_as = candidate.get("sameAs", [])
|
|
164
|
+
if isinstance(same_as, str):
|
|
165
|
+
same_as = [same_as]
|
|
166
|
+
return {
|
|
167
|
+
"name": _text_from_node(candidate.get("name")),
|
|
168
|
+
"description": _text_from_node(candidate.get("description")),
|
|
169
|
+
"url": _text_from_node(candidate.get("url")),
|
|
170
|
+
"thumbnail_url": _text_from_node(image),
|
|
171
|
+
"youtube_id": _text_from_node(identifier),
|
|
172
|
+
"same_as": same_as if isinstance(same_as, list) else [],
|
|
173
|
+
}
|
|
174
|
+
return {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _public_error_from_initial_data(initial_data: dict[str, Any]) -> str:
|
|
178
|
+
alerts = _find_first_key(initial_data, "alerts")
|
|
179
|
+
if not isinstance(alerts, list):
|
|
180
|
+
return ""
|
|
181
|
+
for alert in alerts:
|
|
182
|
+
text = _text_from_node(_find_first_key(alert, "text"))
|
|
183
|
+
if text:
|
|
184
|
+
return text
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
|
|
124
188
|
def _public_page_candidates(source: str) -> list[str]:
|
|
125
189
|
source = source.strip()
|
|
126
190
|
if source.startswith(("http://", "https://")):
|
|
@@ -197,73 +261,134 @@ class YouTubeAdapter:
|
|
|
197
261
|
source = (value or "").strip()
|
|
198
262
|
if not source:
|
|
199
263
|
return IntegrationResult(False, "Introduza o nome, handle, URL ou ID do canal.", {})
|
|
264
|
+
if source.startswith(("http://", "https://")):
|
|
265
|
+
host = (urlparse(source).hostname or "").lower()
|
|
266
|
+
if host != "youtube.com" and not host.endswith(".youtube.com"):
|
|
267
|
+
return IntegrationResult(False, "O link deve pertencer ao YouTube. Use um URL youtube.com, um handle @nome ou um ID UC... .", {"url": source, "public_lookup": True})
|
|
200
268
|
headers = {
|
|
201
269
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
|
202
270
|
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
203
271
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
204
272
|
}
|
|
273
|
+
direct_id = _channel_id_from_source(source)
|
|
274
|
+
feed_cache: dict[str, dict[str, Any]] = {}
|
|
275
|
+
|
|
276
|
+
def feed_for(channel_id: str) -> dict[str, Any]:
|
|
277
|
+
if channel_id and channel_id not in feed_cache:
|
|
278
|
+
feed_cache[channel_id] = _public_feed_data(channel_id, headers)
|
|
279
|
+
return feed_cache.get(channel_id, {})
|
|
280
|
+
|
|
281
|
+
if direct_id:
|
|
282
|
+
direct_feed = feed_for(direct_id)
|
|
283
|
+
if direct_feed.get("name"):
|
|
284
|
+
feed_name = str(direct_feed.get("name", ""))
|
|
285
|
+
else:
|
|
286
|
+
feed_name = ""
|
|
287
|
+
else:
|
|
288
|
+
feed_name = ""
|
|
205
289
|
last_url = source
|
|
206
290
|
last_error = ""
|
|
207
291
|
for page_url in _public_page_candidates(source):
|
|
208
292
|
last_url = page_url
|
|
209
293
|
try:
|
|
210
|
-
response = requests.get(page_url, headers=headers, timeout=20)
|
|
294
|
+
response = requests.get(page_url, headers=headers, timeout=20, allow_redirects=True)
|
|
211
295
|
response.raise_for_status()
|
|
212
296
|
except requests.RequestException as exc:
|
|
213
|
-
last_error =
|
|
297
|
+
last_error = f"Falha HTTP: {exc}"
|
|
214
298
|
continue
|
|
215
299
|
document = response.text or ""
|
|
216
300
|
initial_data = _extract_json_assignment(document, "ytInitialData") or {}
|
|
301
|
+
public_error = _public_error_from_initial_data(initial_data)
|
|
217
302
|
metadata = _find_first_key(initial_data, "channelMetadataRenderer") or {}
|
|
218
303
|
header = _find_first_key(initial_data, "pageHeaderViewModel") or {}
|
|
219
|
-
|
|
304
|
+
jsonld = _jsonld_channel_data(document)
|
|
305
|
+
response_url = str(getattr(response, "url", "") or "")
|
|
306
|
+
canonical_url = (
|
|
307
|
+
_meta_content(document, "og:url", "twitter:url")
|
|
308
|
+
or _canonical_link(document)
|
|
309
|
+
or (response_url if "youtube.com" in response_url else "")
|
|
310
|
+
or jsonld.get("url", "")
|
|
311
|
+
or page_url.split("?", 1)[0].rstrip("/")
|
|
312
|
+
)
|
|
220
313
|
owner_urls = metadata.get("ownerUrls", []) if isinstance(metadata, dict) else []
|
|
221
314
|
if owner_urls:
|
|
222
315
|
canonical_url = str(owner_urls[0])
|
|
223
|
-
canonical_url = canonical_url.replace("http://", "https://").
|
|
316
|
+
canonical_url = canonical_url.replace("http://", "https://").split("?", 1)[0].rstrip("/")
|
|
224
317
|
youtube_id = str(metadata.get("externalId", "")) if isinstance(metadata, dict) else ""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
feed =
|
|
228
|
-
|
|
229
|
-
if not
|
|
230
|
-
|
|
231
|
-
if not
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
318
|
+
youtube_id = youtube_id or _channel_id_from_source(canonical_url) or _channel_id_from_document(document, canonical_url)
|
|
319
|
+
youtube_id = youtube_id or _channel_id_from_source(jsonld.get("youtube_id", "")) or direct_id
|
|
320
|
+
feed = feed_for(youtube_id)
|
|
321
|
+
page_title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
|
|
322
|
+
if not page_title and isinstance(header, dict):
|
|
323
|
+
page_title = _text_from_node(header.get("title"))
|
|
324
|
+
if not page_title:
|
|
325
|
+
page_title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
|
|
326
|
+
page_title = page_title or jsonld.get("name", "") or _meta_content(document, "og:title", "twitter:title")
|
|
327
|
+
title = page_title or feed.get("name", "") or feed_name
|
|
328
|
+
page_description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
|
|
329
|
+
page_description = page_description or jsonld.get("description", "") or _meta_content(document, "description", "og:description")
|
|
330
|
+
description = page_description
|
|
331
|
+
page_thumbnail = _thumbnail_from_metadata(metadata) or jsonld.get("thumbnail_url", "") or _meta_content(document, "og:image", "twitter:image")
|
|
332
|
+
thumbnail_url = page_thumbnail
|
|
333
|
+
handle = ""
|
|
334
|
+
for handle_source in (canonical_url, *jsonld.get("same_as", []), document):
|
|
335
|
+
handle_match = re.search(r"/@([^/?\"'\\]+)", str(handle_source))
|
|
336
|
+
if handle_match:
|
|
337
|
+
handle = f"@{handle_match.group(1)}"
|
|
338
|
+
break
|
|
243
339
|
subscriber_value = _find_first_key(initial_data, "subscriberCountText")
|
|
244
340
|
video_value = _find_first_key(initial_data, "videoCountText")
|
|
245
341
|
data = {
|
|
246
342
|
"youtube_id": youtube_id or feed.get("youtube_id", ""),
|
|
247
343
|
"name": title,
|
|
248
344
|
"handle": handle,
|
|
249
|
-
"url": canonical_url,
|
|
345
|
+
"url": canonical_url or (f"https://www.youtube.com/channel/{youtube_id}" if youtube_id else source),
|
|
250
346
|
"description": description,
|
|
251
347
|
"thumbnail_url": thumbnail_url,
|
|
252
348
|
"subscriber_count": _parse_public_count(subscriber_value),
|
|
253
349
|
"video_count": _parse_public_count(video_value) or feed.get("video_count"),
|
|
254
350
|
"view_count": None,
|
|
255
|
-
"metrics_source": "youtube_public_page",
|
|
351
|
+
"metrics_source": "youtube_public_page" if (page_title or page_description or page_thumbnail) else ("youtube_public_feed" if feed.get("name") else "youtube_public_page"),
|
|
256
352
|
"public_lookup": True,
|
|
257
353
|
}
|
|
258
|
-
if
|
|
354
|
+
if public_error and any(marker in public_error.lower() for marker in ("não existe", "nao existe", "não encontrado", "nao encontrado", "not found", "does not exist")):
|
|
355
|
+
data["public_error"] = public_error
|
|
356
|
+
return IntegrationResult(False, f"O YouTube indicou que este canal não existe ou não está disponível: {public_error}", data)
|
|
357
|
+
has_metadata = bool(
|
|
358
|
+
data["name"]
|
|
359
|
+
or data["handle"]
|
|
360
|
+
or data["description"]
|
|
361
|
+
or data["thumbnail_url"]
|
|
362
|
+
or data["subscriber_count"] is not None
|
|
363
|
+
or data["video_count"] is not None
|
|
364
|
+
)
|
|
365
|
+
if has_metadata:
|
|
259
366
|
return IntegrationResult(True, "Canal encontrado publicamente no YouTube, sem API Key. Reveja os dados antes de guardar.", data)
|
|
260
|
-
last_error = "
|
|
261
|
-
|
|
367
|
+
last_error = "O YouTube respondeu, mas não forneceu metadados públicos reconhecíveis para este canal."
|
|
368
|
+
if direct_id and feed_name:
|
|
369
|
+
data = {
|
|
370
|
+
"youtube_id": direct_id,
|
|
371
|
+
"name": feed_name,
|
|
372
|
+
"handle": "",
|
|
373
|
+
"url": f"https://www.youtube.com/channel/{direct_id}",
|
|
374
|
+
"description": "",
|
|
375
|
+
"thumbnail_url": "",
|
|
376
|
+
"subscriber_count": None,
|
|
377
|
+
"video_count": feed_cache.get(direct_id, {}).get("video_count"),
|
|
378
|
+
"view_count": None,
|
|
379
|
+
"metrics_source": "youtube_public_feed",
|
|
380
|
+
"public_lookup": True,
|
|
381
|
+
}
|
|
382
|
+
return IntegrationResult(True, "Canal encontrado no feed público do YouTube, sem API Key. Reveja os dados antes de guardar.", data)
|
|
383
|
+
data = {"url": last_url, "public_lookup": True}
|
|
384
|
+
if direct_id:
|
|
385
|
+
data["youtube_id"] = direct_id
|
|
386
|
+
return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. {last_error or 'Confirme o URL/handle ou use Cadastro manual.'}".strip(), data)
|
|
262
387
|
|
|
263
388
|
def fetch_channel(self, value: str) -> IntegrationResult:
|
|
264
389
|
ref = self.extract_channel_ref(value)
|
|
265
390
|
if not self.api_key:
|
|
266
|
-
return IntegrationResult(False, "
|
|
391
|
+
return IntegrationResult(False, "A YouTube Data API Key própria não está configurada. Escolha Página pública — sem API Key ou adicione uma chave separada em Configurações; o OAuth Client ID e Secret não substituem esta credencial.", {"url": value, "handle": ref, "status": "api_key_not_configured"})
|
|
267
392
|
try:
|
|
268
393
|
params = {"part": "snippet,statistics", "key": self.api_key}
|
|
269
394
|
if ref.startswith("UC"):
|
package/package.json
CHANGED