@danhachuel/thunderbolt 0.2.50 → 0.2.51

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.50
5
+ > **Versão deste manual:** 0.2.51
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.50 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.51 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.50 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.51 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.
@@ -396,12 +396,21 @@ Após iniciar a aplicação, valide o seguinte percurso:
396
396
  11. **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, OAuth Client Secret e `sessionInfo` próprios; use **Repetir campos para nova conta** para preparar contas adicionais e o ícone **Apagar conta** para eliminar individualmente a conta, tokens, documento JSON e associações de canais.
397
397
  12. **OpenAI/ NVIDIA NIM:** em **LLM — providers e modelos**, seleccione `openai`, confirme a Base URL, introduza a API key e clique em **Consultar/actualizar modelos NIM**. Escolha um modelo da lista ou utilize o fallback manual e guarde as configurações. Confirme que a consulta só ocorre após o clique e que um erro de endpoint não interrompe a UI.
398
398
  13. **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`.
399
- 14. **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.
400
- 15. **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.
401
- 16. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada cartão Gmail apresenta o uploader **Documento de credenciais desta conta Google** e o campo **sessionInfo token desta conta Google**. O sessionInfo é guardado por conta e sincronizado no documento JSON, que reúne `SID`, `SSID`, `HSID`, `APISID`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` por canal. Use **Repetir campos para nova conta** para preencher o formulário seguinte e o ícone **Apagar conta** para remover a conta e os dados privados associados. Em **Canais > Canais cadastrados**, associe cada canal à conta Google do documento; a UI não mostra nem edita o `DELEGATED_SESSION_ID`. O documento é guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, e o uploader lê os valores directos exclusivamente desse documento. Não existem campos técnicos de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na parte inferior da UI e o método não extrai cookies automaticamente do navegador.
402
- 17. **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.
403
- 18. **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.
404
- 19. **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.
399
+ 14. **Canais:** abra **Configurações > Canais**, clique em **Editar** num cartão, altere o nicho, Blueprint/Prompts do Canal, Narrador/Voz padrão e horário, guarde e confirme que o cartão é actualizado sem duplicar o canal. Clique em **Actualizar últimos 10 vídeos**, valide a vista Lista, mude para Kanban e abra **Editar vídeo** para testar título, estado, data, URL e notas.
400
+ 15. **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.
401
+ 16. **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.
402
+ 17. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube canais em lote**, cada cartão Gmail apresenta o uploader **Documento de credenciais desta conta Google** e o campo **sessionInfo token desta conta Google**. O sessionInfo é guardado por conta e sincronizado no documento JSON, que reúne `SID`, `SSID`, `HSID`, `APISID`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` por canal. Use **Repetir campos para nova conta** para preencher o formulário seguinte e o ícone **Apagar conta** para remover a conta e os dados privados associados. Em **Canais > Canais cadastrados**, associe cada canal à conta Google do documento; a UI não mostra nem edita o `DELEGATED_SESSION_ID`. O documento é guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, e o uploader lê os valores directos exclusivamente desse documento. Não existem campos técnicos de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na parte inferior da UI e o método não extrai cookies automaticamente do navegador.
403
+ 18. **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.
404
+ 19. **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.
405
+ 20. **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.
406
+
407
+ ## Gestão de Canais, edição e vídeos recentes
408
+
409
+ Na página **Configurações > Canais**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (Voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
410
+
411
+ O cartão mostra o nicho ou Canais de Referência imediatamente abaixo do nome. Os três blocos de gestão usam os rótulos **Prompts do Canal**, **Canais de Referência** e **Narrador**, com botões que abrem o editor do próprio canal.
412
+
413
+ A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal e não dentro de Criação de Vídeos. Clique em **Actualizar últimos 10 vídeos** para consultar o feed RSS público do YouTube sem Data API Key. Os resultados são guardados em `storage/state/channel_videos.json`. A vista **Lista** apresenta título, data, URL, estado e botão **Editar vídeo**; a vista **Kanban** separa Planejamento, Produção, Finalizado e Agendado/Publicado. A edição local permite alterar título, estado, data, URL e notas. Essas alterações são overrides de gestão local e não publicam automaticamente no YouTube.
405
414
 
406
415
  ## Canais em lote por conta Google/YouTube
407
416
 
@@ -419,7 +428,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
419
428
 
420
429
  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.
421
430
 
422
- 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.50 install`; o instalador detecta e reutiliza o que já estiver válido.
431
+ 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.51 install`; o instalador detecta e reutiliza o que já estiver válido.
423
432
 
424
433
  ### Niche Finder Apify
425
434
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ 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, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente |
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; cartões com botão Editar, nicho/referências visível, Prompts do Canal/Blueprint, Narrador/voz e gestão dos últimos 10 vídeos |
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 |
@@ -36,12 +36,20 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
36
36
 
37
37
  Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
38
38
 
39
- ## Navegação da UI 0.2.50
39
+ ## Navegação da UI 0.2.51
40
40
 
41
41
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
42
42
 
43
43
  Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém contas Google, sessionInfo e documentos de Upload directo; **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
44
44
 
45
+ ## Canais — edição por cartão e vídeos recentes
46
+
47
+ A página **Canais** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. O nicho aparece imediatamente abaixo do nome do canal no cartão; quando não existe, a UI mostra **SEM NICHO CONFIGURADO**.
48
+
49
+ Os blocos do cartão usam a nomenclatura solicitada: **Prompts do Canal**, **Canais de Referência** e **Narrador**. Os botões de acção abrem o mesmo editor persistente, sem criar um segundo canal nem perder as associações existentes.
50
+
51
+ Abaixo do cartão, a secção **Últimos 10 vídeos publicados** usa o feed público RSS do YouTube, sem Data API Key. O carregamento ocorre quando se clica em **Actualizar últimos 10 vídeos**, evitando chamadas automáticas ao abrir a página. Os vídeos ficam guardados em `storage/state/channel_videos.json` e podem ser apresentados em **Lista** ou **Kanban**, nos grupos Planejamento, Produção, Finalizado e Agendado/Publicado. Cada vídeo tem **Editar vídeo** para alterar localmente o título, estado, data, URL e notas. A fonte pública não substitui o vídeo nem publica alterações no YouTube; os campos editáveis são overrides locais de gestão.
52
+
45
53
  ## Criação de Vídeos — geração editorial por canal
46
54
 
47
55
  A aba **Criação de Vídeos** permite escrever manualmente o **Tópico ou briefing** ou usar **Gerar tópico/briefing com IA**. A geração usa o provider LLM configurado localmente em **Configurações Técnicas > API Keys**, incorpora o Blueprint, a descrição, o idioma e a voz do canal e só ocorre depois do clique do utilizador. Se não houver credenciais ou modelo, a UI mostra uma mensagem accionável e não inventa conteúdo.
package/app/main.py CHANGED
@@ -20,7 +20,7 @@ try:
20
20
  except (OSError, json.JSONDecodeError):
21
21
  APP_VERSION = ""
22
22
 
23
- 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
23
+ 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, update_channel_video
24
24
  from hermes_ui.automation_worker import load_worker_status
25
25
  from hermes_ui.storage import BLUEPRINTS, STORAGE, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
26
26
  from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
@@ -35,7 +35,7 @@ from hermes_ui.mcp_server import server_status, start_server, stop_server
35
35
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
36
36
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
37
37
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
38
- from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
38
+ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
39
39
  from integrations.youtube_direct_upload import YouTubeDirectUploader
40
40
  from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
41
41
  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, loopback_redirect_uri
@@ -300,6 +300,172 @@ def channel_default_options(channel: dict) -> tuple[list[str], dict[str, str], s
300
300
  return blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice
301
301
 
302
302
 
303
+ def channel_niche_label(channel: dict) -> str:
304
+ """Return the channel niche/reference label shown under the channel name."""
305
+ niche = str(channel.get("niche") or "").strip()
306
+ if niche:
307
+ return niche
308
+ references = channel.get("reference_channels")
309
+ if isinstance(references, list):
310
+ values = [str(item).strip() for item in references if str(item).strip()]
311
+ if values:
312
+ return ", ".join(values)
313
+ blueprint = blueprint_for_channel(channel)
314
+ metadata = blueprint.get("metadata") if isinstance(blueprint.get("metadata"), dict) else {}
315
+ return str(blueprint.get("target_niche") or blueprint.get("niche") or metadata.get("target_niche") or metadata.get("niche") or "SEM NICHO CONFIGURADO").strip()
316
+
317
+
318
+ def _merge_channel_videos(channel_id: str, videos: list[dict[str, Any]]) -> list[dict[str, Any]]:
319
+ existing = read_json("channel_videos.json", [])
320
+ if not isinstance(existing, list):
321
+ existing = []
322
+ other_channels = [item for item in existing if isinstance(item, dict) and str(item.get("channel_id")) != str(channel_id)]
323
+ current_by_id = {str(item.get("id")): item for item in existing if isinstance(item, dict) and str(item.get("channel_id")) == str(channel_id)}
324
+ merged: list[dict[str, Any]] = []
325
+ for video in videos:
326
+ item = dict(current_by_id.get(str(video.get("id")), {}))
327
+ item.update(video)
328
+ item["channel_id"] = str(channel_id)
329
+ item.setdefault("status", "publicado")
330
+ merged.append(item)
331
+ write_json("channel_videos.json", other_channels + merged)
332
+ return merged
333
+
334
+
335
+ def channel_videos_for(channel: dict, limit: int = 10) -> list[dict[str, Any]]:
336
+ channel_id = str(channel.get("id") or "")
337
+ stored = read_json("channel_videos.json", [])
338
+ videos = [item for item in stored if isinstance(item, dict) and str(item.get("channel_id")) == channel_id]
339
+ videos.sort(key=lambda item: str(item.get("published_at") or item.get("created_at") or ""), reverse=True)
340
+ return videos[:limit]
341
+
342
+
343
+ def render_channel_video_editor(video: dict, channel_id: str) -> None:
344
+ video_id = str(video.get("id") or "")
345
+ edit_key = f"channel_video_edit_{video_id}"
346
+ if st.button("Editar vídeo", key=f"edit_channel_video_{video_id}", use_container_width=True):
347
+ st.session_state[edit_key] = True
348
+ st.rerun()
349
+ if st.session_state.get(edit_key):
350
+ with st.form(f"channel_video_form_{video_id}"):
351
+ edited_title = st.text_input("Título", value=str(video.get("title") or ""))
352
+ edited_status = st.selectbox("Estado", ["planejamento", "produção", "finalizado", "agendado", "publicado"], index=["planejamento", "produção", "finalizado", "agendado", "publicado"].index(str(video.get("status") or "publicado")) if str(video.get("status") or "publicado") in {"planejamento", "produção", "finalizado", "agendado", "publicado"} else 4)
353
+ edited_date = st.text_input("Data de publicação", value=str(video.get("published_at") or ""))
354
+ edited_url = st.text_input("URL", value=str(video.get("url") or ""))
355
+ edited_notes = st.text_area("Notas", value=str(video.get("notes") or ""), height=80)
356
+ save_video = st.form_submit_button("Guardar alteração", type="primary")
357
+ if save_video:
358
+ update_channel_video(video_id, {"title": edited_title.strip(), "status": edited_status, "published_at": edited_date.strip(), "url": edited_url.strip(), "notes": edited_notes.strip()})
359
+ st.session_state.pop(edit_key, None)
360
+ st.success("Vídeo actualizado.")
361
+ st.rerun()
362
+
363
+
364
+ def render_channel_videos(channel: dict) -> None:
365
+ channel_id = str(channel.get("id") or "")
366
+ st.markdown("### Últimos 10 vídeos publicados")
367
+ st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
368
+ refresh_col, view_col = st.columns([1.4, 1])
369
+ with refresh_col:
370
+ if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
371
+ result = fetch_channel_videos_public(channel, limit=10)
372
+ if result.ok:
373
+ videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
374
+ st.session_state[f"channel_videos_{channel_id}"] = videos
375
+ st.success(result.message)
376
+ st.rerun()
377
+ else:
378
+ st.warning(result.message)
379
+ with view_col:
380
+ view_mode = st.radio("Vista", ["Lista", "Kanban"], horizontal=True, key=f"channel_videos_view_{channel_id}", label_visibility="collapsed")
381
+ videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
382
+ if not videos:
383
+ st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
384
+ return
385
+ if view_mode == "Lista":
386
+ for video in videos[:10]:
387
+ with st.container(border=True):
388
+ cols = st.columns([0.7, 3.4, 1.4, 1.1])
389
+ with cols[0]:
390
+ if video.get("thumbnail_url"):
391
+ st.image(video["thumbnail_url"], width=64)
392
+ else:
393
+ st.markdown("### YT")
394
+ with cols[1]:
395
+ st.write(f"**{video.get('title', 'Vídeo sem título')}**")
396
+ st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
397
+ with cols[2]:
398
+ st.caption(str(video.get("status") or "publicado").title())
399
+ with cols[3]:
400
+ render_channel_video_editor(video, channel_id)
401
+ else:
402
+ columns = st.columns(4)
403
+ groups = [("planejamento", "Planejamento"), ("produção", "Produção"), ("finalizado", "Finalizado"), ("publicado", "Agendado/Publicado")]
404
+ for column, (status_key, label) in zip(columns, groups):
405
+ with column:
406
+ st.markdown(f"**{label}**")
407
+ group = [video for video in videos[:10] if str(video.get("status") or "publicado").lower() in ({status_key} if status_key != "publicado" else {"publicado", "agendado"})]
408
+ if not group:
409
+ st.caption("Sem vídeos")
410
+ for video in group:
411
+ with st.container(border=True):
412
+ st.write(f"**{video.get('title', 'Vídeo sem título')}**")
413
+ st.caption(video.get("published_at") or "Sem data")
414
+ render_channel_video_editor(video, channel_id)
415
+
416
+
417
+ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], youtube_account_labels: dict[str, str], youtube_accounts_by_id: dict[str, dict[str, Any]]) -> None:
418
+ channel_id = str(channel["id"])
419
+ blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
420
+ account_ids = list(youtube_account_ids)
421
+ current_account = str(channel.get("google_account_id") or "")
422
+ if current_account and current_account not in account_ids:
423
+ account_ids.append(current_account)
424
+ youtube_account_labels[current_account] = "Conta Google não configurada"
425
+ with st.form(f"channel_edit_form_{channel_id}"):
426
+ st.subheader("Editar canal")
427
+ edit_cols = st.columns(2)
428
+ with edit_cols[0]:
429
+ edited_name = st.text_input("Nome do canal", value=str(channel.get("name") or ""))
430
+ edited_url = st.text_input("URL", value=str(channel.get("url") or ""))
431
+ edited_handle = st.text_input("Handle", value=str(channel.get("handle") or ""))
432
+ edited_language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=["Português", "English", "Español", "Français", "Deutsch"].index(channel.get("language")) if channel.get("language") in {"Português", "English", "Español", "Français", "Deutsch"} else 0)
433
+ style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
434
+ style_value = {"pexels": "Pexels/Pixabay", "music": "Apenas Música"}.get(str(channel.get("style_wide") or "pexels"), str(channel.get("style_wide") or "Pexels/Pixabay"))
435
+ edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
436
+ edited_niche = st.text_input("Canais de Referência / Nicho", value=str(channel.get("niche") or channel_niche_label(channel) if channel_niche_label(channel) != "SEM NICHO CONFIGURADO" else "") )
437
+ with edit_cols[1]:
438
+ edited_blueprint = st.selectbox("Prompts do Canal", blueprint_ids, index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"))
439
+ edited_voice = st.selectbox("Narrador", 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")
440
+ edited_account = st.selectbox("Conta Google para Upload directo", account_ids, index=account_ids.index(current_account) if current_account in account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"))
441
+ edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
442
+ edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
443
+ edited_time = st.text_input("Horário diário (HH:MM)", value=str(channel.get("automation_time") or "00:00"))
444
+ save_channel = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
445
+ cancel_edit = st.form_submit_button("Cancelar edição")
446
+ if cancel_edit:
447
+ st.session_state.pop(f"edit_channel_{channel_id}", None)
448
+ st.rerun()
449
+ if save_channel:
450
+ if not edited_name.strip():
451
+ st.error("Informe o nome do canal.")
452
+ elif not valid_hhmm(edited_time):
453
+ st.error("O horário diário deve estar no formato HH:MM.")
454
+ else:
455
+ update_channel(channel_id, {
456
+ "name": edited_name.strip(), "url": edited_url.strip(), "handle": edited_handle.strip(), "language": edited_language,
457
+ "style_wide": {"Pexels/Pixabay": "pexels", "Apenas Música": "music"}.get(edited_style, edited_style),
458
+ "niche": edited_niche.strip(), "reference_channels": [item.strip() for item in re.split(r"[,|]", edited_niche) if item.strip()],
459
+ "default_blueprint_id": edited_blueprint.strip(), "blueprint_id": edited_blueprint.strip(),
460
+ "default_voice": edited_voice.strip(), "voice": edited_voice.strip(),
461
+ "google_account_id": edited_account.strip(), "google_account_email": str(youtube_accounts_by_id.get(edited_account, {}).get("email", "")),
462
+ "description": edited_description.strip(), "automation_on": bool(edited_automation), "automation_time": edited_time.strip(),
463
+ })
464
+ st.session_state.pop(f"edit_channel_{channel_id}", None)
465
+ st.success("Canal actualizado.")
466
+ st.rerun()
467
+
468
+
303
469
  def render_dashboard():
304
470
  st.title("Thunderbolt")
305
471
  st.caption("Interface local para operação e automação de conteúdo faceless")
@@ -482,6 +648,7 @@ def render_channels():
482
648
  automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
483
649
  automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
484
650
  description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
651
+ niche = st.text_input("Canais de Referência / Nicho", value=imported.get("niche", ""), key="yt_import_niche")
485
652
  metrics = st.columns(3)
486
653
  with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
487
654
  with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=int(imported.get("video_count") or 0), key="yt_import_videos")
@@ -497,6 +664,8 @@ def render_channels():
497
664
  **imported,
498
665
  "handle": handle.strip(),
499
666
  "description": description.strip(),
667
+ "niche": niche.strip(),
668
+ "reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
500
669
  "language": language,
501
670
  "style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
502
671
  "blueprint_id": blueprint.strip(),
@@ -615,6 +784,7 @@ def render_channels():
615
784
  url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
616
785
  handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
617
786
  description = st.text_area("Descrição", key="manual_channel_description")
787
+ niche = st.text_input("Canais de Referência / Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
618
788
  language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
619
789
  style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
620
790
  manual_blueprint_items = blueprint_catalog()
@@ -642,6 +812,8 @@ def render_channels():
642
812
  channel = create_channel(name, url, {
643
813
  "handle": handle.strip(),
644
814
  "description": description.strip(),
815
+ "niche": niche.strip(),
816
+ "reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
645
817
  "language": language,
646
818
  "style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
647
819
  "blueprint_id": blueprint.strip(),
@@ -667,99 +839,94 @@ def render_channels():
667
839
  st.info("Nenhum canal cadastrado.")
668
840
  return
669
841
  for channel in channels:
842
+ channel_id = str(channel["id"])
843
+ edit_key = f"edit_channel_{channel_id}"
670
844
  with st.container(border=True):
671
- channel_account_ids = list(youtube_account_ids)
672
- current_channel_account_id = str(channel.get("google_account_id", ""))
673
- if current_channel_account_id and current_channel_account_id not in channel_account_ids:
674
- channel_account_ids.append(current_channel_account_id)
675
- youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
676
- st.markdown("**Upload directo — documento da conta deste canal**")
677
- st.caption("O DELEGATED_SESSION_ID deste canal é individual, mas fica apenas no documento JSON da conta Google. A UI não mostra nem edita esse valor; associe apenas o canal à conta que contém o documento.")
678
- with st.form(f"channel_direct_credentials_{channel['id']}"):
679
- channel_account_id = st.selectbox("Conta Google do documento deste canal", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel['id']}")
680
- save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
681
- selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
682
- if selected_channel_account:
683
- selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
684
- if selected_account_status["ready"]:
685
- st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
686
- elif not selected_account_status["document_exists"]:
687
- st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
688
- else:
689
- missing_channel_parts = list(selected_account_status["missing_cookies"])
690
- if not selected_account_status["has_session_info"]:
691
- missing_channel_parts.append("sessionInfo")
692
- if not selected_account_status["has_innertube_api_key"]:
693
- missing_channel_parts.append("INNERTUBE_API_KEY")
694
- if not selected_account_status["has_delegated_session_id"]:
695
- missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
696
- st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
697
- else:
698
- st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
699
- if save_channel_direct_credentials:
700
- 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", ""))})
701
- st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
702
- st.rerun()
703
- cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
704
- with cols[0]:
845
+ header_cols = st.columns([0.7, 3.5, 1.3, 1.3, 1.5])
846
+ with header_cols[0]:
705
847
  if channel.get("thumbnail_url"):
706
- st.image(channel["thumbnail_url"], width=54)
848
+ st.image(channel["thumbnail_url"], width=64)
707
849
  else:
708
850
  st.markdown("### YT")
709
- with cols[1]:
851
+ with header_cols[1]:
710
852
  st.write(f"**{channel.get('name', 'Sem nome')}**")
853
+ st.caption(channel_niche_label(channel))
711
854
  st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'} · {channel.get('metrics_source', 'manual')}")
712
- with cols[2]: st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
713
- with cols[3]: st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
714
- with cols[4]: st.metric("Backlog", channel.get("backlog_total", 0))
715
- with cols[5]:
716
- active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel['id']}")
855
+ with header_cols[2]:
856
+ st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
857
+ with header_cols[3]:
858
+ st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
859
+ with header_cols[4]:
860
+ active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel_id}")
717
861
  if active != channel.get("active"):
718
- update_channel(channel["id"], {"active": active})
862
+ update_channel(channel_id, {"active": active})
719
863
  st.rerun()
720
- delete_key = f"delete_pending_{channel['id']}"
721
- if not st.session_state.get(delete_key, False):
722
- if st.button("Apagar canal", key=f"delete_{channel['id']}", use_container_width=True):
864
+ action_cols = st.columns(2)
865
+ with action_cols[0]:
866
+ if st.button("Editar", key=f"edit_channel_button_{channel_id}", use_container_width=True):
867
+ st.session_state[edit_key] = True
868
+ st.rerun()
869
+ with action_cols[1]:
870
+ delete_key = f"delete_pending_{channel_id}"
871
+ if st.button("Apagar", key=f"delete_{channel_id}", use_container_width=True):
723
872
  st.session_state[delete_key] = True
724
873
  st.rerun()
874
+ if st.session_state.get(edit_key):
875
+ render_channel_edit_form(channel, youtube_account_ids, youtube_account_labels, youtube_accounts_by_id)
876
+ else:
877
+ summary = channel_blueprint_summary(channel)
878
+ block_cols = st.columns(3)
879
+ with block_cols[0]:
880
+ st.markdown(f"**Prompts do Canal**\n\n{summary['name']}")
881
+ if st.button("Editar Prompts", key=f"edit_prompts_{channel_id}", use_container_width=True):
882
+ st.session_state[edit_key] = True
883
+ st.rerun()
884
+ with block_cols[1]:
885
+ st.markdown(f"**Canais de Referência**\n\n{channel_niche_label(channel)}")
886
+ if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
887
+ st.session_state[edit_key] = True
888
+ st.rerun()
889
+ with block_cols[2]:
890
+ st.markdown(f"**Narrador**\n\n{summary['voice'] or 'Sem voz padrão'}")
891
+ if st.button("Configurar Narrador", key=f"edit_voice_{channel_id}", use_container_width=True):
892
+ st.session_state[edit_key] = True
893
+ st.rerun()
894
+
895
+ channel_account_ids = list(youtube_account_ids)
896
+ current_channel_account_id = str(channel.get("google_account_id", ""))
897
+ if current_channel_account_id and current_channel_account_id not in channel_account_ids:
898
+ channel_account_ids.append(current_channel_account_id)
899
+ youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
900
+ with st.expander("Upload directo — documento da conta deste canal", expanded=False):
901
+ st.caption("O DELEGATED_SESSION_ID deste canal é individual, mas fica apenas no documento JSON da conta Google. A UI não mostra nem edita esse valor; associe apenas o canal à conta que contém o documento.")
902
+ with st.form(f"channel_direct_credentials_{channel_id}"):
903
+ channel_account_id = st.selectbox("Conta Google do documento deste canal", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel_id}")
904
+ save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
905
+ selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
906
+ if selected_channel_account:
907
+ selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
908
+ if selected_account_status["ready"]:
909
+ st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
910
+ elif not selected_account_status["document_exists"]:
911
+ st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
912
+ else:
913
+ missing_channel_parts = list(selected_account_status["missing_cookies"])
914
+ if not selected_account_status["has_session_info"]:
915
+ missing_channel_parts.append("sessionInfo")
916
+ if not selected_account_status["has_innertube_api_key"]:
917
+ missing_channel_parts.append("INNERTUBE_API_KEY")
918
+ if not selected_account_status["has_delegated_session_id"]:
919
+ missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
920
+ st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
725
921
  else:
726
- st.warning("As tarefas, vídeos e artefactos relacionados serão preservados.")
727
- confirm_col, cancel_col = st.columns(2)
728
- with confirm_col:
729
- if st.button("Confirmar apagar", key=f"confirm_delete_{channel['id']}", type="primary", use_container_width=True):
730
- removed = delete_channel(channel["id"])
731
- st.session_state.pop(delete_key, None)
732
- if removed:
733
- st.success(f"Canal {removed.get('name', 'seleccionado')} apagado.")
734
- st.rerun()
735
- with cancel_col:
736
- if st.button("Cancelar", key=f"cancel_delete_{channel['id']}", use_container_width=True):
737
- st.session_state.pop(delete_key, None)
738
- st.rerun()
739
- blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
740
- with st.expander("Definir Blueprint e voz padrão", expanded=False):
741
- editor_cols = st.columns(2)
742
- with editor_cols[0]:
743
- channel_blueprint = st.selectbox(
744
- "Blueprint padrão",
745
- blueprint_ids,
746
- index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
747
- format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
748
- key=f"channel_default_blueprint_{channel['id']}",
749
- )
750
- with editor_cols[1]:
751
- channel_voice = st.selectbox(
752
- "Voz padrão",
753
- voice_options,
754
- index=voice_options.index(current_voice) if current_voice in voice_options else 0,
755
- format_func=lambda item: item or "Sem voz padrão",
756
- key=f"channel_default_voice_{channel['id']}",
757
- )
758
- if st.button("Guardar Blueprint e voz", key=f"save_channel_defaults_{channel['id']}", type="primary"):
759
- set_channel_defaults(channel["id"], channel_blueprint, channel_voice)
760
- st.success("Blueprint padrão e voz padrão guardados para este canal.")
922
+ st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
923
+ if save_channel_direct_credentials:
924
+ 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", ""))})
925
+ st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
761
926
  st.rerun()
762
927
 
928
+ render_channel_videos(channel)
929
+
763
930
 
764
931
  def render_new_video(page_title: str = "Criação de Vídeos"):
765
932
  st.title(page_title)
@@ -29,6 +29,8 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
29
29
  "url": url.strip(),
30
30
  "handle": "",
31
31
  "description": "",
32
+ "niche": "",
33
+ "reference_channels": [],
32
34
  "thumbnail_url": "",
33
35
  "subscriber_count": None,
34
36
  "video_count": None,
@@ -184,6 +186,18 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
184
186
  return created
185
187
 
186
188
 
189
+ def update_channel_video(video_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
190
+ """Actualizar um vídeo remoto sincronizado ou um override local de vídeo do canal."""
191
+ videos = read_json("channel_videos.json", [])
192
+ for video in videos:
193
+ if video.get("id") == video_id:
194
+ video.update(updates)
195
+ video["updated_at"] = now()
196
+ write_json("channel_videos.json", videos)
197
+ return video
198
+ return None
199
+
200
+
187
201
  def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
188
202
  tasks = read_json("tasks.json", [])
189
203
  for task in tasks:
@@ -16,8 +16,9 @@ NICHES_DATA = STORAGE / "data" / "niches"
16
16
  SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
17
17
 
18
18
  DEFAULTS: dict[str, Any] = {
19
- "channels.json": [],
20
- "tasks.json": [],
19
+ "channels.json": [],
20
+ "channel_videos.json": [],
21
+ "tasks.json": [],
21
22
  "queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
22
23
  "batches.json": [],
23
24
  "uploads.json": [],
@@ -238,6 +238,80 @@ def _public_feed_data(channel_id: str, headers: dict[str, str]) -> dict[str, Any
238
238
  return {}
239
239
 
240
240
 
241
+ def fetch_channel_videos_public(channel_ref: str | dict[str, Any], limit: int = 10) -> "IntegrationResult":
242
+ """Fetch the latest public channel videos from YouTube's Atom feed without an API key."""
243
+ if isinstance(channel_ref, dict):
244
+ source = str(channel_ref.get("youtube_channel_id") or channel_ref.get("url") or "").strip()
245
+ else:
246
+ source = str(channel_ref or "").strip()
247
+ if not source:
248
+ return IntegrationResult(False, "Este canal ainda não tem ID ou URL pública do YouTube.", {"videos": []})
249
+ headers = {
250
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
251
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
252
+ "Accept": "application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8",
253
+ }
254
+ channel_id = _channel_id_from_source(source)
255
+ if not channel_id and source.startswith(("http://", "https://")):
256
+ for page_url in _public_page_candidates(source):
257
+ try:
258
+ response = requests.get(page_url, headers=headers, timeout=12, allow_redirects=True)
259
+ response.raise_for_status()
260
+ except requests.RequestException:
261
+ continue
262
+ document = response.text or ""
263
+ channel_id = _channel_id_from_document(document, str(getattr(response, "url", "") or page_url))
264
+ if channel_id:
265
+ break
266
+ if not channel_id:
267
+ return IntegrationResult(False, "Não foi possível resolver o ID público do canal para carregar os vídeos.", {"videos": []})
268
+ feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
269
+ try:
270
+ response = requests.get(feed_url, headers=headers, timeout=12)
271
+ response.raise_for_status()
272
+ root = ET.fromstring(response.text)
273
+ except (requests.RequestException, ET.ParseError, ValueError) as exc:
274
+ return IntegrationResult(False, f"Não foi possível carregar os vídeos públicos do canal: {exc}", {"channel_id": channel_id, "videos": []})
275
+ namespace = {
276
+ "yt": "http://www.youtube.com/xml/schemas/2015",
277
+ "atom": "http://www.w3.org/2005/Atom",
278
+ "media": "http://search.yahoo.com/mrss/",
279
+ }
280
+ videos: list[dict[str, Any]] = []
281
+ max_items = max(1, min(10, int(limit or 10)))
282
+ for entry in root.findall("atom:entry", namespace)[:max_items]:
283
+ video_id = entry.findtext("yt:videoId", default="", namespaces=namespace).strip()
284
+ title = entry.findtext("atom:title", default="", namespaces=namespace).strip()
285
+ published_at = entry.findtext("atom:published", default="", namespaces=namespace).strip()
286
+ updated_at = entry.findtext("atom:updated", default="", namespaces=namespace).strip()
287
+ link = ""
288
+ for candidate in entry.findall("atom:link", namespace):
289
+ if candidate.attrib.get("rel", "alternate") == "alternate":
290
+ link = candidate.attrib.get("href", "")
291
+ break
292
+ if not link and video_id:
293
+ link = f"https://www.youtube.com/watch?v={video_id}"
294
+ thumbnail = ""
295
+ media_group = entry.find("media:group", namespace)
296
+ if media_group is not None:
297
+ media_thumbnail = media_group.find("media:thumbnail", namespace)
298
+ if media_thumbnail is not None:
299
+ thumbnail = str(media_thumbnail.attrib.get("url", ""))
300
+ videos.append({
301
+ "id": f"youtube_{video_id}" if video_id else f"youtube_{len(videos)}",
302
+ "youtube_video_id": video_id,
303
+ "channel_id": channel_id,
304
+ "title": title or "Vídeo sem título",
305
+ "published_at": published_at,
306
+ "updated_at": updated_at,
307
+ "url": link,
308
+ "thumbnail_url": thumbnail,
309
+ "source": "youtube_public_rss",
310
+ "status": "publicado",
311
+ })
312
+ return IntegrationResult(True, f"{len(videos)} vídeo(s) público(s) carregado(s) sem API Key.", {"channel_id": channel_id, "videos": videos})
313
+
314
+
241
315
  @dataclass
242
316
  class IntegrationResult:
243
317
  ok: bool
@@ -385,6 +459,9 @@ class YouTubeAdapter:
385
459
  data["youtube_id"] = direct_id
386
460
  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)
387
461
 
462
+ def fetch_channel_videos_public(self, channel_ref: str | dict[str, Any], limit: int = 10) -> IntegrationResult:
463
+ return fetch_channel_videos_public(channel_ref, limit=limit)
464
+
388
465
  def fetch_channel(self, value: str) -> IntegrationResult:
389
466
  ref = self.extract_channel_ref(value)
390
467
  if not self.api_key:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.50",
3
+ "version": "0.2.51",
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",