@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.
- package/MANUAL-INSTALACAO.md +19 -10
- package/README.md +10 -2
- package/app/main.py +249 -82
- package/hermes_ui/domain.py +14 -0
- package/hermes_ui/storage.py +3 -2
- package/integrations/platforms.py +77 -0
- 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.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.
|
|
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.
|
|
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. **
|
|
400
|
-
15. **
|
|
401
|
-
16. **
|
|
402
|
-
17. **Configurações >
|
|
403
|
-
18. **Configurações > MCP >
|
|
404
|
-
19. **Configurações > MCP >
|
|
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.
|
|
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.
|
|
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
|
-
|
|
672
|
-
|
|
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=
|
|
848
|
+
st.image(channel["thumbnail_url"], width=64)
|
|
707
849
|
else:
|
|
708
850
|
st.markdown("### YT")
|
|
709
|
-
with
|
|
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
|
|
713
|
-
|
|
714
|
-
with
|
|
715
|
-
|
|
716
|
-
|
|
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(
|
|
862
|
+
update_channel(channel_id, {"active": active})
|
|
719
863
|
st.rerun()
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if st.button("
|
|
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.
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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)
|
package/hermes_ui/domain.py
CHANGED
|
@@ -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:
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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