@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.
@@ -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.22
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.22 install
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.22 install
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 importar nome, handle e métricas públicas de canais |
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 é a Data API Key |
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 chaves devem ser inseridas apenas na configuração local. Não as coloque no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
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:** configure **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. A UI não inicia jobs em segundo plano nesta entrega.
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.22 install
108
- npx.cmd --yes @danhachuel/thunderbolt@0.2.22 doctor
109
- npx.cmd --yes @danhachuel/thunderbolt@0.2.22
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 `youtube_api_key` estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`.
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, {"automation_on": bool(enabled), "automation_time": schedule_time.strip()})
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 — API pública e OAuth 2.0**")
822
- st.caption("A Data API Key consulta canais e estatísticas públicas. O Client ID e o Client Secret são credenciais OAuth 2.0 separadas, usadas para autorizar operações na conta, como upload.")
823
- youtube_cols = st.columns(3)
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
- youtube_api_key = text_setting("YouTube Data API Key", "youtube_api_key", secret=True, help_text="Chave criada em Google Cloud > APIs e serviços > Credenciais > Chave de API.")
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
- youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud.")
828
- with youtube_cols[2]:
829
- youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0.")
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. é 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.")
@@ -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 = str(exc)
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
- canonical_url = _meta_content(document, "og:url", "twitter:url") or page_url.split("?", 1)[0].rstrip("/")
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://").removesuffix("/about")
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
- if not youtube_id:
226
- youtube_id = _channel_id_from_document(document, canonical_url)
227
- feed = _public_feed_data(youtube_id, headers)
228
- title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
229
- if not title and isinstance(header, dict):
230
- title = _text_from_node(header.get("title"))
231
- if not title:
232
- title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
233
- if not title:
234
- title = _meta_content(document, "og:title", "twitter:title") or feed.get("name", "")
235
- description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
236
- description = description or _meta_content(document, "description", "og:description")
237
- thumbnail_url = _thumbnail_from_metadata(metadata) or _meta_content(document, "og:image", "twitter:image")
238
- handle_match = re.search(r"/@([^/?]+)", canonical_url)
239
- handle = f"@{handle_match.group(1)}" if handle_match else ""
240
- if not handle:
241
- handle_match = re.search(r"/@([^/?]+)", document)
242
- handle = f"@{handle_match.group(1)}" if handle_match else ""
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 data["name"] or data["youtube_id"] or data["thumbnail_url"]:
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 = "A página respondeu sem metadados reconhecíveis."
261
- return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. Confirme o URL/handle ou use Cadastro manual. {last_error}".strip(), {"url": last_url, "public_lookup": True})
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, "YOUTUBE_API_KEY não configurada; preencha os dados manualmente.", {"url": value, "handle": ref})
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",