@danhachuel/thunderbolt 0.3.37 → 0.3.38

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.3.37
5
+ > **Versão deste manual:** 0.3.38
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.3.37 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.3.38 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.3.37 install
109
+ npx --yes @danhachuel/thunderbolt@0.3.38 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.
@@ -459,11 +459,11 @@ A subaba de fontes não é um painel de tuning: endpoints, proxy, qualidade, cor
459
459
 
460
460
  ## Gestão de Canais, edição e vídeos recentes
461
461
 
462
- Na página **Configurações > Canais Youtube**, 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.
462
+ Na página **Configurações > Canais Youtube**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Nicho**, **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.
463
463
 
464
- 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.
464
+ O cartão mostra o nicho imediatamente abaixo do nome. Os quatro blocos compactos de gestão usam os rótulos **Blueprint Padrão**, **Nicho**, **Narrador/Voz Padrão** e **Idioma**, com os botões de edição correspondentes; o idioma é apenas apresentado a partir da configuração já guardada no canal.
465
465
 
466
- 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.
466
+ A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal, dentro de um expander fechado por defeito, e não dentro de Criação de Vídeos. Clique no expander e depois 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 única vista disponível é **Lista**, que apresenta título, data, URL, estado e botão **Editar vídeo**. 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.
467
467
 
468
468
  ## Canais em lote por conta Google/YouTube
469
469
 
@@ -481,7 +481,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
481
481
 
482
482
  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.
483
483
 
484
- 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.3.37 install`; o instalador detecta e reutiliza o que já estiver válido.
484
+ 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.3.38 install`; o instalador detecta e reutiliza o que já estiver válido.
485
485
 
486
486
  ### Niche Finder Apify
487
487
 
@@ -519,7 +519,7 @@ O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`,
519
519
 
520
520
  A aba **Roteiros**, colocada entre **Criação de Músicas** e **Upload**, permite seleccionar um canal opcional, um Blueprint, **Roteiro de vídeo** ou **Letra de música**, idioma, tema e estrutura. O botão **Gerar com IA a partir do Blueprint** usa o provider LLM configurado e devolve um rascunho Markdown editável; o utilizador deve rever e clicar em **Guardar documento no storage**. Os ficheiros são guardados em `storage/scripts/` e o índice em `storage/state/scripts.json`; o caminho absoluto aparece no topo da página. Na subaba **Vídeos** de **Criação de Vídeos**, a aplicação mostra a frase `Os vídeos são guardados em <storage>/videos`, que identifica a pasta local dos vídeos.
521
521
 
522
- A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado.
522
+ A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista os canais e vídeos e apresenta nos cards os mesmos quatro elementos do cadastro: **Idioma Padrão**, **Blueprint Padrão**, **Nicho Padrão** e **Narrador/Voz Padrão**. Blueprint e Narrador/Voz continuam editáveis no card; Idioma e Nicho são mostrados a partir da configuração guardada no canal. A aba também guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado.
523
523
 
524
524
  A área **Teste de vozes**, dentro de **Configurações > Configuração API**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
525
525
 
package/README.md CHANGED
@@ -69,7 +69,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
69
69
 
70
70
  A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
71
71
 
72
- ## Navegação da UI 0.3.37
72
+ ## Navegação da UI 0.3.38
73
73
 
74
74
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
75
75
 
@@ -91,11 +91,11 @@ A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https:
91
91
 
92
92
  ## Canais Youtube — edição por cartão e vídeos recentes
93
93
 
94
- A página **Canais Youtube** 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**.
94
+ A página **Canais Youtube** 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, **Nicho**, **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**.
95
95
 
96
- 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.
96
+ Os quatro blocos compactos do cartão usam a nomenclatura solicitada: **Blueprint Padrão**, **Nicho**, **Narrador/Voz Padrão** e **Idioma**. Os botões de acção abrem o mesmo editor persistente, sem criar um segundo canal nem perder as associações existentes.
97
97
 
98
- 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.
98
+ Abaixo do cartão, a secção **Últimos 10 vídeos publicados** fica num expander fechado por defeito e 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 são apresentados apenas no modo **Lista**. 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.
99
99
 
100
100
  ## Criação de Vídeos — geração editorial por canal
101
101
 
package/app/main.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
- import html
5
4
  import json
6
5
  import mimetypes
7
6
  import re
@@ -208,30 +207,6 @@ st.markdown("""
208
207
  [data-testid="stSidebar"] [data-testid="stExpander"] summary p { margin:0; line-height:1; }
209
208
  [data-testid="stSidebar"] [data-testid="stExpander"] > div { padding:0 0 0 0.42rem !important; }
210
209
  [data-testid="stSidebar"] [data-testid="stExpander"] > div [data-testid="stButton"] button { padding-left:0.92rem; font-size:0.83rem; min-height:1.62rem; height:1.62rem; }
211
- .tb-channel-kanban-card { box-sizing:border-box; min-height:330px; height:330px; padding:0.85rem 0.9rem; border:1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius:14px; background:color-mix(in srgb, currentColor 5%, transparent); color:inherit; box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); overflow:hidden; }
212
- .tb-channel-kanban-card__header { display:flex; align-items:center; gap:0.58rem; min-width:0; }
213
- .tb-channel-kanban-card__avatar { width:46px; height:46px; flex:0 0 46px; border-radius:10px; object-fit:cover; background:color-mix(in srgb, var(--tb-accent) 14%, transparent); }
214
- .tb-channel-kanban-card__avatar--fallback { display:flex; align-items:center; justify-content:center; font-weight:800; color:var(--tb-accent); }
215
- .tb-channel-kanban-card__identity { min-width:0; flex:1 1 auto; }
216
- .tb-channel-kanban-card__name { font-size:1rem; font-weight:750; line-height:1.18; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
217
- .tb-channel-kanban-card__niche { margin-top:0.22rem; font-size:0.76rem; opacity:.66; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
218
- .tb-channel-kanban-card__badges { display:flex; gap:0.28rem; flex:0 0 auto; align-items:center; }
219
- .tb-channel-kanban-card__badge { padding:0.18rem 0.42rem; border-radius:6px; font-size:0.67rem; font-weight:750; line-height:1.1; white-space:nowrap; }
220
- .tb-channel-kanban-card__badge--youtube { background:#dc2626; color:#fff; }
221
- .tb-channel-kanban-card__badge--active { background:color-mix(in srgb, #22c55e 25%, transparent); color:#16a34a; border:1px solid color-mix(in srgb, #22c55e 50%, transparent); }
222
- .tb-channel-kanban-card__badge--inactive { background:color-mix(in srgb, #ef4444 18%, transparent); color:#dc2626; border:1px solid color-mix(in srgb, #ef4444 45%, transparent); }
223
- .tb-channel-kanban-card__rule { height:1px; margin:0.72rem 0 0.62rem; background:color-mix(in srgb, currentColor 13%, transparent); }
224
- .tb-channel-kanban-card__metrics { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:0.34rem; }
225
- .tb-channel-kanban-card__metric { min-width:0; min-height:67px; padding:0.48rem 0.28rem 0.36rem; border-radius:8px; text-align:center; background:color-mix(in srgb, currentColor 4%, transparent); }
226
- .tb-channel-kanban-card__metric-value { font-size:1.12rem; font-weight:800; line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
227
- .tb-channel-kanban-card__metric-label { margin-top:0.3rem; font-size:0.66rem; opacity:.64; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
228
- .tb-channel-kanban-card__status-row { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:0.34rem; margin-top:0.6rem; }
229
- .tb-channel-kanban-card__status { min-width:0; padding:0.42rem 0.28rem; border-radius:8px; text-align:center; font-size:0.71rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
230
- .tb-channel-kanban-card__status strong { font-size:0.92rem; margin-right:0.18rem; }
231
- .tb-channel-kanban-card__status--doing { color:#d97706; background:color-mix(in srgb, #f59e0b 14%, transparent); border:1px solid color-mix(in srgb, #f59e0b 35%, transparent); }
232
- .tb-channel-kanban-card__status--done { color:#16a34a; background:color-mix(in srgb, #22c55e 11%, transparent); border:1px solid color-mix(in srgb, #22c55e 30%, transparent); }
233
- .tb-channel-kanban-card__titles { margin-top:0.58rem; font-size:0.73rem; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
234
- @media (max-width: 900px) { .tb-channel-kanban-card { min-height:310px; height:310px; } .tb-channel-kanban-card__name { font-size:.9rem; } }
235
210
  .content-card { box-sizing:border-box; padding:1rem 1.1rem; border:1px solid rgba(128,128,128,.28); border-radius:14px; background:rgba(128,128,128,.10); color:inherit; min-height:110px; box-shadow:0 4px 14px rgba(0,0,0,.12); }
236
211
  .content-card { border:1px solid color-mix(in srgb, currentColor 18%, transparent); background:color-mix(in srgb, currentColor 5%, transparent); box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); }
237
212
  .content-label { color:inherit; opacity:.72; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
@@ -968,38 +943,38 @@ def render_channel_video_editor(video: dict, channel_id: str) -> None:
968
943
 
969
944
  def render_channel_videos(channel: dict) -> None:
970
945
  channel_id = str(channel.get("id") or "")
971
- st.markdown("### Últimos 10 vídeos publicados")
972
- st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
973
- refresh_col = st.columns(1)[0]
974
- with refresh_col:
975
- if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
976
- result = fetch_channel_videos_public(channel, limit=10)
977
- if result.ok:
978
- videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
979
- st.session_state[f"channel_videos_{channel_id}"] = videos
980
- st.success(result.message)
981
- st.rerun()
982
- else:
983
- st.warning(result.message)
984
- videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
985
- if not videos:
986
- st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
987
- return
988
- for video in videos[:10]:
989
- with st.container(border=True):
990
- cols = st.columns([0.7, 3.4, 1.4, 1.1])
991
- with cols[0]:
992
- if video.get("thumbnail_url"):
993
- st.image(video["thumbnail_url"], width=64)
946
+ with st.expander("Últimos 10 vídeos publicados", expanded=False):
947
+ st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
948
+ refresh_col = st.columns(1)[0]
949
+ with refresh_col:
950
+ if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
951
+ result = fetch_channel_videos_public(channel, limit=10)
952
+ if result.ok:
953
+ videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
954
+ st.session_state[f"channel_videos_{channel_id}"] = videos
955
+ st.success(result.message)
956
+ st.rerun()
994
957
  else:
995
- st.markdown("### YT")
996
- with cols[1]:
997
- st.write(f"**{video.get('title', 'Vídeo sem título')}**")
998
- st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
999
- with cols[2]:
1000
- st.caption(str(video.get("status") or "publicado").title())
1001
- with cols[3]:
1002
- render_channel_video_editor(video, channel_id)
958
+ st.warning(result.message)
959
+ videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
960
+ if not videos:
961
+ st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
962
+ return
963
+ for video in videos[:10]:
964
+ with st.container(border=True):
965
+ cols = st.columns([0.7, 3.4, 1.4, 1.1])
966
+ with cols[0]:
967
+ if video.get("thumbnail_url"):
968
+ st.image(video["thumbnail_url"], width=64)
969
+ else:
970
+ st.markdown("### YT")
971
+ with cols[1]:
972
+ st.write(f"**{video.get('title', 'Vídeo sem título')}**")
973
+ st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
974
+ with cols[2]:
975
+ st.caption(str(video.get("status") or "publicado").title())
976
+ with cols[3]:
977
+ render_channel_video_editor(video, channel_id)
1003
978
 
1004
979
 
1005
980
  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:
@@ -1021,10 +996,10 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
1021
996
  style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
1022
997
  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"))
1023
998
  edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
1024
- 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 "") )
999
+ edited_niche = st.text_input("Nicho", value=str(channel.get("niche") or channel_niche_label(channel) if channel_niche_label(channel) != "SEM NICHO CONFIGURADO" else "") )
1025
1000
  with edit_cols[1]:
1026
- 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"))
1027
- 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")
1001
+ edited_blueprint = st.selectbox("Blueprint Padrão", 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"))
1002
+ edited_voice = st.selectbox("Narrador/Voz Padrão", 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")
1028
1003
  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"))
1029
1004
  edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
1030
1005
  edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
@@ -1438,101 +1413,6 @@ def render_tiktok_prompt_masters():
1438
1413
  st.error(str(exc))
1439
1414
 
1440
1415
 
1441
- def _format_channel_metric(value: Any) -> str:
1442
- """Format a channel metric compactly for the Kanban card."""
1443
- if value is None or value == "":
1444
- return "—"
1445
- try:
1446
- number = int(value)
1447
- except (TypeError, ValueError):
1448
- return html.escape(str(value))
1449
- if abs(number) >= 1_000_000:
1450
- return f"{number / 1_000_000:.1f}M".replace(".0M", "M")
1451
- if abs(number) >= 1_000:
1452
- return f"{number / 1_000:.1f}K".replace(".0K", "K")
1453
- return f"{number:,}".replace(",", ".")
1454
-
1455
-
1456
- def _channel_pipeline_counts(channel: dict[str, Any]) -> tuple[int, int, int]:
1457
- """Return in-production, completed and titled task counts for one channel."""
1458
- channel_id = str(channel.get("id") or "")
1459
- tasks = read_json("tasks.json", [])
1460
- if not channel_id or not isinstance(tasks, list):
1461
- return 0, 0, 0
1462
- related = [
1463
- task for task in tasks
1464
- if isinstance(task, dict) and str(task.get("channel_id") or "") == channel_id
1465
- ]
1466
- in_production = sum(1 for task in related if str(task.get("state") or "").lower() == "doing")
1467
- completed = sum(1 for task in related if str(task.get("state") or "").lower() == "done")
1468
- titled = sum(1 for task in related if str(task.get("title") or "").strip() or task.get("title_candidates"))
1469
- return in_production, completed, titled
1470
-
1471
-
1472
- def _render_channel_kanban_card(channel: dict[str, Any]) -> None:
1473
- """Render one compact, square-style channel card for the global Kanban view."""
1474
- ui_language = current_ui_language()
1475
- name = html.escape(str(channel.get("name") or "Sem nome"))
1476
- niche = html.escape(channel_niche_label(channel))
1477
- thumbnail_url = str(channel.get("thumbnail_url") or "").strip()
1478
- if thumbnail_url:
1479
- avatar = f'<img class="tb-channel-kanban-card__avatar" src="{html.escape(thumbnail_url, quote=True)}" alt="">'
1480
- else:
1481
- avatar = '<div class="tb-channel-kanban-card__avatar tb-channel-kanban-card__avatar--fallback">YT</div>'
1482
- active = bool(channel.get("active", True))
1483
- active_label = ui_text("Activo" if active else "Inactivo", ui_language)
1484
- active_class = "active" if active else "inactive"
1485
- subscribers = _format_channel_metric(channel.get("subscriber_count"))
1486
- views = _format_channel_metric(channel.get("view_count"))
1487
- videos = _format_channel_metric(channel.get("video_count"))
1488
- in_production, completed, titled = _channel_pipeline_counts(channel)
1489
- youtube_label = ui_text("YouTube", ui_language)
1490
- subscribers_label = ui_text("Inscritos", ui_language)
1491
- views_label = ui_text("Visualizações", ui_language)
1492
- videos_label = ui_text("Vídeos", ui_language)
1493
- production_label = ui_text("Em produção", ui_language)
1494
- completed_label = ui_text("Finalizados", ui_language)
1495
- titles_label = ui_text("Títulos", ui_language)
1496
- st.markdown(
1497
- f"""
1498
- <div class="tb-channel-kanban-card">
1499
- <div class="tb-channel-kanban-card__header">
1500
- {avatar}
1501
- <div class="tb-channel-kanban-card__identity">
1502
- <div class="tb-channel-kanban-card__name">{name}</div>
1503
- <div class="tb-channel-kanban-card__niche">{niche}</div>
1504
- </div>
1505
- <div class="tb-channel-kanban-card__badges">
1506
- <span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--youtube">{youtube_label}</span>
1507
- <span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--{active_class}">{active_label}</span>
1508
- </div>
1509
- </div>
1510
- <div class="tb-channel-kanban-card__rule"></div>
1511
- <div class="tb-channel-kanban-card__metrics">
1512
- <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{subscribers}</div><div class="tb-channel-kanban-card__metric-label">{subscribers_label}</div></div>
1513
- <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{views}</div><div class="tb-channel-kanban-card__metric-label">{views_label}</div></div>
1514
- <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{videos}</div><div class="tb-channel-kanban-card__metric-label">{videos_label}</div></div>
1515
- </div>
1516
- <div class="tb-channel-kanban-card__status-row">
1517
- <div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--doing"><strong>{in_production}</strong>{production_label}</div>
1518
- <div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--done"><strong>{completed}</strong>{completed_label}</div>
1519
- </div>
1520
- <div class="tb-channel-kanban-card__titles">▣ <strong>{titled}</strong> {titles_label}</div>
1521
- </div>
1522
- """,
1523
- unsafe_allow_html=True,
1524
- )
1525
-
1526
-
1527
- def render_registered_channels_kanban(channels: list[dict[str, Any]]) -> None:
1528
- """Render all registered channels in a responsive three-column Kanban grid."""
1529
- for row_start in range(0, len(channels), 3):
1530
- channel_columns = st.columns(3)
1531
- for column, channel in zip(channel_columns, channels[row_start:row_start + 3]):
1532
- with column:
1533
- _render_channel_kanban_card(channel)
1534
-
1535
-
1536
1416
  def render_channels():
1537
1417
  st.title("Canais Youtube")
1538
1418
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
@@ -1552,7 +1432,7 @@ def render_channels():
1552
1432
  with lookup_cols[0]:
1553
1433
  lookup_mode = st.radio("Método de consulta", ["Página pública — sem API Key", "YouTube Data API — API Key opcional"], horizontal=True, key="youtube_channel_lookup_mode")
1554
1434
  with lookup_cols[1]:
1555
- st.radio("Apresentação dos canais", ["Lista", "Kanban"], horizontal=True, key="youtube_channels_view_mode")
1435
+ st.caption("Os canais cadastrados são apresentados apenas em lista.")
1556
1436
  col1, col2 = st.columns([1, 1])
1557
1437
  with col1:
1558
1438
  if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
@@ -1588,17 +1468,17 @@ def render_channels():
1588
1468
  handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
1589
1469
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index(language_code(imported.get("language") or "pt")), format_func=language_label, key="yt_import_language")
1590
1470
  style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="yt_import_style")
1591
- blueprint = st.selectbox("Blueprint padrão do canal", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
1471
+ blueprint = st.selectbox("Blueprint Padrão", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
1592
1472
  voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
1593
1473
  current_voice = imported.get("default_voice") or imported.get("voice", "")
1594
- voice = st.selectbox("Voz padrão do canal", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
1474
+ voice = st.selectbox("Narrador/Voz Padrão", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
1595
1475
  imported_account_id = str(imported.get("google_account_id", ""))
1596
1476
  google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, index=youtube_account_ids.index(imported_account_id) if imported_account_id in youtube_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item), key="yt_import_google_account_id")
1597
1477
  st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
1598
1478
  automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
1599
1479
  automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
1600
1480
  description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
1601
- niche = st.text_input("Canais de Referência / Nicho", value=imported.get("niche", ""), key="yt_import_niche")
1481
+ niche = st.text_input("Nicho", value=imported.get("niche", ""), key="yt_import_niche")
1602
1482
  metrics = st.columns(3)
1603
1483
  with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
1604
1484
  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")
@@ -1734,15 +1614,15 @@ def render_channels():
1734
1614
  url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
1735
1615
  handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
1736
1616
  description = st.text_area("Descrição", key="manual_channel_description")
1737
- niche = st.text_input("Canais de Referência / Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
1617
+ niche = st.text_input("Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
1738
1618
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="manual_channel_language")
1739
1619
  style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
1740
1620
  manual_blueprint_items = blueprint_catalog()
1741
1621
  manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
1742
1622
  manual_blueprint_labels = {item[0]: item[1] for item in manual_blueprint_items}
1743
- blueprint = st.selectbox("Blueprint padrão do canal", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
1623
+ blueprint = st.selectbox("Blueprint Padrão", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
1744
1624
  voice_options = voice_catalog()
1745
- voice = st.selectbox("Voz padrão do canal", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
1625
+ voice = st.selectbox("Narrador/Voz Padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
1746
1626
  google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, format_func=lambda item: youtube_account_labels.get(item, item), key="manual_channel_google_account_id")
1747
1627
  st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
1748
1628
  automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
@@ -1788,9 +1668,6 @@ def render_channels():
1788
1668
  if not channels:
1789
1669
  st.info("Nenhum canal cadastrado.")
1790
1670
  return
1791
- if st.session_state.get("youtube_channels_view_mode", "Lista") == "Kanban":
1792
- render_registered_channels_kanban(channels)
1793
- return
1794
1671
  for channel in channels:
1795
1672
  channel_id = str(channel["id"])
1796
1673
  edit_key = f"edit_channel_{channel_id}"
@@ -1828,22 +1705,25 @@ def render_channels():
1828
1705
  render_channel_edit_form(channel, youtube_account_ids, youtube_account_labels, youtube_accounts_by_id)
1829
1706
  else:
1830
1707
  summary = channel_blueprint_summary(channel)
1831
- block_cols = st.columns(3)
1708
+ channel_language = language_label(channel.get("language") or "pt")
1709
+ block_cols = st.columns(4, gap="small")
1832
1710
  with block_cols[0]:
1833
- st.markdown(f"**Prompts do Canal**\n\n{summary['name']}")
1834
- if st.button("Editar Prompts", key=f"edit_prompts_{channel_id}", use_container_width=True):
1711
+ st.markdown(f"**Blueprint Padrão**\n\n{summary['name']}")
1712
+ if st.button("Editar Blueprint", key=f"edit_prompts_{channel_id}", use_container_width=True):
1835
1713
  st.session_state[edit_key] = True
1836
1714
  st.rerun()
1837
1715
  with block_cols[1]:
1838
- st.markdown(f"**Canais de Referência**\n\n{channel_niche_label(channel)}")
1716
+ st.markdown(f"**Nicho**\n\n{channel_niche_label(channel)}")
1839
1717
  if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
1840
1718
  st.session_state[edit_key] = True
1841
1719
  st.rerun()
1842
1720
  with block_cols[2]:
1843
- st.markdown(f"**Narrador**\n\n{summary['voice'] or 'Sem voz padrão'}")
1844
- if st.button("Configurar Narrador", key=f"edit_voice_{channel_id}", use_container_width=True):
1721
+ st.markdown(f"**Narrador/Voz Padrão**\n\n{summary['voice'] or 'Sem voz padrão'}")
1722
+ if st.button("Configurar Narrador/Voz", key=f"edit_voice_{channel_id}", use_container_width=True):
1845
1723
  st.session_state[edit_key] = True
1846
1724
  st.rerun()
1725
+ with block_cols[3]:
1726
+ st.markdown(f"**Idioma**\n\n{channel_language}")
1847
1727
 
1848
1728
  channel_account_ids = list(youtube_account_ids)
1849
1729
  current_channel_account_id = str(channel.get("google_account_id", ""))
@@ -3691,20 +3571,25 @@ def render_automation():
3691
3571
  with cols[1]:
3692
3572
  st.write(f"**{channel.get('name', 'Sem nome')}**")
3693
3573
  st.caption(channel.get("handle") or channel.get("url") or "sem URL")
3694
- st.caption(f"Blueprint actual: {channel.get('default_blueprint_id') or channel.get('blueprint_id') or '—'} · Voz actual: {channel.get('default_voice') or channel.get('voice') or '—'}")
3695
3574
  blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
3696
- default_cols = st.columns(2)
3575
+ default_cols = st.columns(4, gap="small")
3697
3576
  with default_cols[0]:
3577
+ st.markdown("**Idioma Padrão**")
3578
+ st.caption(language_label(channel.get("language") or "pt"))
3579
+ with default_cols[1]:
3698
3580
  automation_blueprint = st.selectbox(
3699
- "Blueprint padrão",
3581
+ "Blueprint Padrão",
3700
3582
  blueprint_ids,
3701
3583
  index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
3702
3584
  format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
3703
3585
  key=f"automation_blueprint_{channel_id}",
3704
3586
  )
3705
- with default_cols[1]:
3587
+ with default_cols[2]:
3588
+ st.markdown("**Nicho Padrão**")
3589
+ st.caption(channel_niche_label(channel))
3590
+ with default_cols[3]:
3706
3591
  automation_voice = st.selectbox(
3707
- "Voz padrão",
3592
+ "Narrador/Voz Padrão",
3708
3593
  voice_options,
3709
3594
  index=voice_options.index(current_voice) if current_voice in voice_options else 0,
3710
3595
  format_func=lambda item: item or "Sem voz padrão",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.37",
3
+ "version": "0.3.38",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",