@danhachuel/thunderbolt 0.2.17 → 0.2.19
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 -7
- package/README.md +31 -10
- package/app/main.py +330 -57
- package/hermes_ui/mcp.py +145 -0
- package/hermes_ui/storage.py +43 -1
- package/integrations/data/azure_voices.json +1326 -0
- package/integrations/platforms.py +196 -2
- package/integrations/youtube_upload.py +367 -0
- package/package.json +3 -1
- package/requirements.txt +3 -0
- package/scripts/cli.mjs +1 -0
- package/scripts/install.mjs +2 -1
- package/seed/skills/moneyprinterturbo-video.md +132 -0
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.19
|
|
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.19 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.19 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.
|
|
@@ -348,6 +348,8 @@ Na primeira execução, abra **Configurações** e reveja:
|
|
|
348
348
|
| YouTube Data API Key | Permitir importar nome, handle e métricas públicas de canais |
|
|
349
349
|
| YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para operações autenticadas |
|
|
350
350
|
| YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é a Data API Key |
|
|
351
|
+
| YouTube upload principal | Lógica do `youtube-automation-agent` adaptada e executada dentro do Thunderbolt |
|
|
352
|
+
| OAuth directo de redundância | Caminho alternativo accionado automaticamente se o agente falhar |
|
|
351
353
|
| TikTok Client ID/Secret | Credenciais da aplicação; Redirect URI, scopes e autorização ficam no TikTok for Developers Playground |
|
|
352
354
|
|
|
353
355
|
As chaves devem ser inseridas apenas na configuração local. Não as coloque no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
|
|
@@ -359,14 +361,22 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
359
361
|
1. **Dashboard:** confirme que a UI abre e mostra o estado local.
|
|
360
362
|
2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
|
|
361
363
|
3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
|
|
362
|
-
4. **Canais:**
|
|
364
|
+
4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** ou a Data API opcional; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
|
|
363
365
|
5. **Novo vídeo:** teste primeiro o modo **Canal específico** e depois os modos de lote.
|
|
364
|
-
6. **Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar
|
|
365
|
-
7. **Upload:** confirme
|
|
366
|
+
6. **Novo vídeo > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba.
|
|
367
|
+
7. **Upload:** autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key é exclusivamente para consultas públicas.
|
|
366
368
|
8. **Limpador de metadado:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos da aba Novo vídeo não são aceites nesta área.
|
|
367
369
|
9. **Configurações:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
370
|
+
10. **MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é apenas uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
|
|
371
|
+
11. **Skill MCP:** 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.
|
|
368
372
|
|
|
369
|
-
##
|
|
373
|
+
## MCP e integrações externas
|
|
374
|
+
|
|
375
|
+
A aba **MCP** é um configurador local de quatro serviços opcionais. As portas iniciais são `3123` para Short Video Maker, `3001` para AutoVio, `8000` como referência editável para OpenMontage e `8787` para a API do OpenCut. A página consulta apenas `http://127.0.0.1:<porta>/` com timeout curto; não clona, instala ou inicia processos externos.
|
|
376
|
+
|
|
377
|
+
O botão **Guardar skill localmente** copia a skill anexada para `storage/skills/`. Esta pasta fica fora dos estados JSON e é criada pelo instalador e pelo launcher. Os quatro repositórios continuam fora do pacote npm.
|
|
378
|
+
|
|
379
|
+
## 11. Limpador de metadado
|
|
370
380
|
|
|
371
381
|
A aba **Limpador de metadado** recebe somente vídeos externos já prontos. O ficheiro enviado é copiado para `storage/metadata_cleaner/originals/`; a aplicação não altera o original. Ao aplicar a operação, o FFmpeg remove os metadados existentes do contentor e cria uma nova versão em `storage/metadata_cleaner/outputs/` com o título, descrição, tags, idioma e outros campos preenchidos.
|
|
372
382
|
|
|
@@ -506,6 +516,8 @@ Nunca publique ou envie por chat:
|
|
|
506
516
|
|
|
507
517
|
Use secrets do GitHub Actions apenas para publicação do pacote. Para execução local, mantenha credenciais fora do repositório e use a área de configurações local ou variáveis de ambiente.
|
|
508
518
|
|
|
519
|
+
A publicação YouTube segue primeiro a lógica adaptada do `PublishingSchedulingAgent` do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent), sob licença MIT. O código corre dentro do processo Streamlit; não é necessário iniciar o agente Node separadamente. O fallback OAuth directo só é chamado quando a tentativa primária falha.
|
|
520
|
+
|
|
509
521
|
## 14. Referências
|
|
510
522
|
|
|
511
523
|
[1]: https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md — MoneyPrinterTurbo: requisitos, instalação, dependências, Streamlit e FFmpeg.
|
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ A primeira versão implementa a camada UI independente com:
|
|
|
14
14
|
| Pipeline | Filas por etapa |
|
|
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 |
|
|
18
|
-
| Novo vídeo |
|
|
19
|
-
|
|
|
20
|
-
|
|
|
17
|
+
| Canais | Subabas de importação pública sem API Key, Data API opcional e cadastro manual independente |
|
|
18
|
+
| Novo vídeo | Subabas Criar vídeo e Vídeos; canal específico, lote no mesmo canal, lote geral, filtro de estados e controlos iniciar/parar |
|
|
19
|
+
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, TikTok e diagnóstico |
|
|
20
|
+
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
21
21
|
| Limpador de metadado | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
|
|
22
22
|
| Configurações | Provedores LLM, TTS/voz, materiais, Whisper, FFmpeg, YouTube, TikTok Client ID/Secret e Upload-Post |
|
|
23
23
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
@@ -103,9 +103,9 @@ thunderbolt
|
|
|
103
103
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
104
104
|
|
|
105
105
|
```powershell
|
|
106
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
107
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
108
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
106
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.19 install
|
|
107
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.19 doctor
|
|
108
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.19
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -162,12 +162,33 @@ A descrição segue a composição do workflow de referência: preview, secção
|
|
|
162
162
|
|
|
163
163
|
## Canais YouTube
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
A aba **Canais** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando `youtube_api_key` estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`.
|
|
166
|
+
|
|
167
|
+
Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo e Blueprint associado. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar.
|
|
168
|
+
|
|
169
|
+
## Upload YouTube
|
|
170
|
+
|
|
171
|
+
O Upload usa como caminho **principal** a lógica do `PublishingSchedulingAgent` do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent), adaptada para Python e executada dentro do processo Streamlit do Thunderbolt. Não é necessário instalar ou iniciar um segundo servidor Node. O fluxo valida o MP4 real, constrói `snippet/status`, faz upload resumível e tenta enviar thumbnail e legendas.
|
|
172
|
+
|
|
173
|
+
Na aba **Upload**, configure primeiro o **YouTube OAuth Client ID** e o **YouTube OAuth Client Secret** em **Configurações**. Em seguida, use **Autorizar agente YouTube**. O navegador local abrirá a autorização Google e o token será guardado apenas em `storage/state/youtube_agent_tokens.json`. Se a publicação principal falhar, o Thunderbolt tenta automaticamente o **OAuth directo de redundância**, usando o token local compatível; a Data API Key nunca é usada para publicar.
|
|
174
|
+
|
|
175
|
+
Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separada para o caminho de redundância. Os resultados guardam no histórico local qual mecanismo foi utilizado e as tentativas realizadas, sem guardar segredos.
|
|
176
|
+
|
|
177
|
+
## MCP e integrações externas opcionais
|
|
178
|
+
|
|
179
|
+
A aba **MCP** funciona como um catálogo local de clientes externos. Ela lista **Short Video Maker**, **AutoVio**, **OpenMontage** e **OpenCut** com os respectivos links oficiais, protocolo/capacidade conhecida, porta local editável e toggle **Activo**. Os repositórios não são clonados, instalados ou incluídos no pacote npm do Thunderbolt.
|
|
180
|
+
|
|
181
|
+
As portas iniciais são `3123` para Short Video Maker, `3001` para a API backend do AutoVio, `8000` como referência editável para OpenMontage — que não documenta uma porta HTTP padrão — e `8787` para a API do OpenCut, cujo frontend de desenvolvimento usa `5173`. A detecção é passiva: apenas consulta `localhost` e nunca inicia processos externos. Activar uma integração guarda a preferência local; não substitui a instalação/configuração do serviço externo.
|
|
182
|
+
|
|
183
|
+
A mesma aba permite **Guardar skill localmente**, copiando `moneyprinterturbo-video.md` para `storage/skills/`, e **Descarregar skill .md** através do navegador. A skill é um recurso separado dos quatro repositórios externos.
|
|
166
184
|
|
|
167
185
|
## TikTok
|
|
168
186
|
|
|
169
|
-
A
|
|
187
|
+
A subaba **Vídeos**, dentro de **Novo vídeo**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
|
|
188
|
+
|
|
189
|
+
A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret.
|
|
190
|
+
Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
|
|
170
191
|
|
|
171
192
|
## Segurança
|
|
172
193
|
|
|
173
|
-
Não coloque chaves, cookies, tokens YouTube ou segredos TikTok no Git. Use a configuração local, variáveis de ambiente ou um ficheiro fora do repositório. O `.gitignore` exclui o storage de estado real, ambientes virtuais e ficheiros de segredo.
|
|
194
|
+
Não coloque chaves, cookies, tokens YouTube ou segredos TikTok no Git. Use a configuração local, variáveis de ambiente ou um ficheiro fora do repositório. O `.gitignore` exclui o storage de estado real, ambientes virtuais e ficheiros de segredo. O adaptador interno foi baseado na lógica publicada sob licença MIT do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent).
|
package/app/main.py
CHANGED
|
@@ -18,10 +18,66 @@ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_
|
|
|
18
18
|
from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
19
19
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
20
20
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
21
|
-
from
|
|
21
|
+
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, read_packaged_skill, update_integration
|
|
22
|
+
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
22
23
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
23
24
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
24
25
|
|
|
26
|
+
VIDEO_LANGUAGE_OPTIONS = [
|
|
27
|
+
"00 – Apenas Música de Fundo (Sem Falas)",
|
|
28
|
+
"01 – Inglês",
|
|
29
|
+
"02 – Norueguês",
|
|
30
|
+
"03 – Dinamarquês",
|
|
31
|
+
"04 – Sueco",
|
|
32
|
+
"05 – Holandês",
|
|
33
|
+
"06 – Alemão",
|
|
34
|
+
"07 – Luxemburguês",
|
|
35
|
+
"08 – Finlandês",
|
|
36
|
+
"09 – Hebraico",
|
|
37
|
+
"10 – Japonês",
|
|
38
|
+
"11 – Árabe (Golfo)",
|
|
39
|
+
"12 – Islandês",
|
|
40
|
+
"13 – Espanhol (Espanha)",
|
|
41
|
+
"14 – Francês",
|
|
42
|
+
"15 – Italiano",
|
|
43
|
+
"16 – Coreano",
|
|
44
|
+
"16 – Irlandês",
|
|
45
|
+
"17 – Estoniano",
|
|
46
|
+
"18 – Grego",
|
|
47
|
+
"19 – Esloveno",
|
|
48
|
+
"20 – Polonês",
|
|
49
|
+
"21 – Tcheco",
|
|
50
|
+
"22 – Lituano",
|
|
51
|
+
"23 – Português (Portugal)",
|
|
52
|
+
"24 – Eslovaco",
|
|
53
|
+
"25 – Letão",
|
|
54
|
+
"26 – Ucraniano",
|
|
55
|
+
"27 – Húngaro",
|
|
56
|
+
"28 – Afrikaans",
|
|
57
|
+
"29 – Turco",
|
|
58
|
+
"30 – Romeno",
|
|
59
|
+
"31 – Russo",
|
|
60
|
+
"32 – Croata",
|
|
61
|
+
"33 – Árabe (Magreb)",
|
|
62
|
+
"34 – Sérvio",
|
|
63
|
+
"35 – Búlgaro",
|
|
64
|
+
"36 – Português (Brasil)",
|
|
65
|
+
"37 – Cantonês",
|
|
66
|
+
"38 – Persa (Farsi)",
|
|
67
|
+
"39 – Mandarim",
|
|
68
|
+
"40 – Malaio",
|
|
69
|
+
"41 – Espanhol (LatAm)",
|
|
70
|
+
"42 – Vietnamita",
|
|
71
|
+
"43 – Filipino (Tagalog)",
|
|
72
|
+
"44 – Indonésio",
|
|
73
|
+
"45 – Malayalam",
|
|
74
|
+
"46 – Tailandês",
|
|
75
|
+
"47 – Télugo",
|
|
76
|
+
"48 – Tamil",
|
|
77
|
+
"49 – Bengali",
|
|
78
|
+
"50 – Hausa",
|
|
79
|
+
]
|
|
80
|
+
|
|
25
81
|
ensure_storage()
|
|
26
82
|
st.set_page_config(page_title="Thunderbolt", page_icon="T", layout="wide", initial_sidebar_state="expanded")
|
|
27
83
|
|
|
@@ -194,38 +250,107 @@ def render_blueprints():
|
|
|
194
250
|
|
|
195
251
|
def render_channels():
|
|
196
252
|
st.title("Canais")
|
|
197
|
-
st.caption("
|
|
198
|
-
|
|
199
|
-
|
|
253
|
+
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
254
|
+
settings = read_json("settings.json", {})
|
|
255
|
+
youtube = YouTubeAdapter(settings=settings)
|
|
256
|
+
import_tab, manual_tab = st.tabs(["Importar do YouTube", "Cadastro manual"])
|
|
257
|
+
|
|
258
|
+
with import_tab:
|
|
259
|
+
st.caption("A pesquisa pública funciona sem API Key. A Data API é opcional e fica disponível numa opção separada para métricas oficiais.")
|
|
260
|
+
source = st.text_input("URL, handle ou ID do canal", placeholder="https://youtube.com/@seucanal", key="youtube_channel_source")
|
|
261
|
+
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")
|
|
200
262
|
col1, col2 = st.columns([1, 1])
|
|
201
263
|
with col1:
|
|
202
|
-
if st.button("Buscar no YouTube", type="primary", use_container_width=True):
|
|
203
|
-
|
|
264
|
+
if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
|
|
265
|
+
if lookup_mode.startswith("Página pública"):
|
|
266
|
+
result = youtube.fetch_channel_public(source)
|
|
267
|
+
elif not youtube.api_key:
|
|
268
|
+
result = IntegrationResult(False, "A YouTube Data API Key não está configurada. Escolha a opção Página pública — sem API Key ou configure a chave em Configurações.", {"status": "api_key_not_configured"})
|
|
269
|
+
else:
|
|
270
|
+
result = youtube.fetch_channel(source)
|
|
204
271
|
st.session_state["yt_import"] = result.data
|
|
205
272
|
st.session_state["yt_message"] = result.message
|
|
206
273
|
st.session_state["yt_ok"] = result.ok
|
|
207
274
|
with col2:
|
|
208
|
-
if st.button("Limpar importação", use_container_width=True):
|
|
209
|
-
|
|
275
|
+
if st.button("Limpar importação", use_container_width=True, key="youtube_channel_clear"):
|
|
276
|
+
for key in ("yt_import", "yt_message", "yt_ok"):
|
|
277
|
+
st.session_state.pop(key, None)
|
|
278
|
+
st.rerun()
|
|
210
279
|
if st.session_state.get("yt_message"):
|
|
211
280
|
(st.success if st.session_state.get("yt_ok") else st.warning)(st.session_state["yt_message"])
|
|
212
281
|
imported = st.session_state.get("yt_import", {})
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
282
|
+
if imported:
|
|
283
|
+
st.caption("Dados encontrados. Reveja ou edite os campos antes de guardar.")
|
|
284
|
+
with st.form("channel_import_form"):
|
|
285
|
+
name = st.text_input("Nome do canal", value=imported.get("name", ""), key="yt_import_name")
|
|
286
|
+
url = st.text_input("URL", value=imported.get("url", source if source.startswith("http") else ""), key="yt_import_url")
|
|
287
|
+
handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
|
|
288
|
+
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="yt_import_language")
|
|
289
|
+
style = st.selectbox("Estilo wide", ["pexels", "full_ia"], index=0, key="yt_import_style")
|
|
290
|
+
blueprint = st.text_input("Blueprint associado", value="", key="yt_import_blueprint")
|
|
291
|
+
description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
|
|
292
|
+
metrics = st.columns(3)
|
|
293
|
+
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
|
|
294
|
+
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")
|
|
295
|
+
with metrics[2]: view_count = st.number_input("Visualizações", min_value=0, value=int(imported.get("view_count") or 0), key="yt_import_views")
|
|
296
|
+
submitted = st.form_submit_button("Guardar canal importado", type="primary")
|
|
297
|
+
if submitted:
|
|
298
|
+
if not name.strip():
|
|
299
|
+
st.error("Informe o nome do canal antes de guardar.")
|
|
300
|
+
else:
|
|
301
|
+
metadata = {
|
|
302
|
+
**imported,
|
|
303
|
+
"handle": handle.strip(),
|
|
304
|
+
"description": description.strip(),
|
|
305
|
+
"language": language,
|
|
306
|
+
"style_wide": style,
|
|
307
|
+
"blueprint_id": blueprint.strip(),
|
|
308
|
+
"subscriber_count": int(subscriber_count) or None,
|
|
309
|
+
"video_count": int(video_count) or None,
|
|
310
|
+
"view_count": int(view_count) or None,
|
|
311
|
+
"metrics_source": imported.get("metrics_source", "youtube_public_page"),
|
|
312
|
+
}
|
|
313
|
+
channel = create_channel(name, url, metadata)
|
|
314
|
+
st.success(f"Canal {channel['name']} guardado.")
|
|
315
|
+
st.rerun()
|
|
316
|
+
else:
|
|
317
|
+
st.info("Introduza um URL, handle ou ID e clique em Buscar no YouTube. Não é necessária API Key na opção pública.")
|
|
318
|
+
|
|
319
|
+
with manual_tab:
|
|
320
|
+
st.caption("Este fluxo não consulta o YouTube e não exige API Key. Preencha os dados que quiser e guarde o canal localmente.")
|
|
321
|
+
with st.form("channel_manual_form"):
|
|
322
|
+
name = st.text_input("Nome do canal", key="manual_channel_name")
|
|
323
|
+
url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
|
|
324
|
+
handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
|
|
325
|
+
description = st.text_area("Descrição", key="manual_channel_description")
|
|
326
|
+
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
|
|
327
|
+
style = st.selectbox("Estilo wide", ["pexels", "full_ia"], index=0, key="manual_channel_style")
|
|
328
|
+
blueprint = st.text_input("Blueprint associado", key="manual_channel_blueprint")
|
|
329
|
+
thumbnail_url = st.text_input("URL da imagem do canal", key="manual_channel_thumbnail")
|
|
330
|
+
metrics = st.columns(3)
|
|
331
|
+
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=0, key="manual_channel_subscribers")
|
|
332
|
+
with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=0, key="manual_channel_videos")
|
|
333
|
+
with metrics[2]: view_count = st.number_input("Visualizações", min_value=0, value=0, key="manual_channel_views")
|
|
334
|
+
submitted = st.form_submit_button("Guardar canal manual", type="primary")
|
|
221
335
|
if submitted:
|
|
222
336
|
if not name.strip():
|
|
223
337
|
st.error("Informe o nome do canal.")
|
|
224
338
|
else:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
339
|
+
channel = create_channel(name, url, {
|
|
340
|
+
"handle": handle.strip(),
|
|
341
|
+
"description": description.strip(),
|
|
342
|
+
"language": language,
|
|
343
|
+
"style_wide": style,
|
|
344
|
+
"blueprint_id": blueprint.strip(),
|
|
345
|
+
"thumbnail_url": thumbnail_url.strip(),
|
|
346
|
+
"subscriber_count": int(subscriber_count) or None,
|
|
347
|
+
"video_count": int(video_count) or None,
|
|
348
|
+
"view_count": int(view_count) or None,
|
|
349
|
+
"metrics_source": "manual",
|
|
350
|
+
})
|
|
351
|
+
st.success(f"Canal {channel['name']} guardado manualmente.")
|
|
228
352
|
st.rerun()
|
|
353
|
+
|
|
229
354
|
st.subheader("Canais cadastrados")
|
|
230
355
|
channels = read_json("channels.json", [])
|
|
231
356
|
if not channels:
|
|
@@ -241,7 +366,7 @@ def render_channels():
|
|
|
241
366
|
st.markdown("### YT")
|
|
242
367
|
with cols[1]:
|
|
243
368
|
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
244
|
-
st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'}")
|
|
369
|
+
st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'} · {channel.get('metrics_source', 'manual')}")
|
|
245
370
|
with cols[2]: st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
|
|
246
371
|
with cols[3]: st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
|
|
247
372
|
with cols[4]: st.metric("Backlog", channel.get("backlog_total", 0))
|
|
@@ -253,41 +378,54 @@ def render_channels():
|
|
|
253
378
|
|
|
254
379
|
def render_new_video():
|
|
255
380
|
st.title("Novo vídeo")
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
262
|
-
if mode == "general":
|
|
263
|
-
selected = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid))
|
|
264
|
-
else:
|
|
265
|
-
selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"])
|
|
266
|
-
selected = [selected_one["id"]]
|
|
267
|
-
with st.form("new_video_form"):
|
|
268
|
-
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
269
|
-
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
270
|
-
language = st.selectbox("Idioma", ["Português", "English", "Español"], key="video_language")
|
|
271
|
-
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
272
|
-
style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
|
|
273
|
-
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
274
|
-
if submitted:
|
|
275
|
-
if not topic.strip() or not selected:
|
|
276
|
-
st.error("Informe um tópico e seleccione pelo menos um canal.")
|
|
381
|
+
create_tab, videos_tab = st.tabs(["Criar vídeo", "Vídeos"])
|
|
382
|
+
with create_tab:
|
|
383
|
+
channels = channel_options()
|
|
384
|
+
if not channels:
|
|
385
|
+
st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
|
|
277
386
|
else:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
387
|
+
mode_label = st.radio("Modo de criação", ["Canal específico", "Lote no mesmo canal", "Lote geral"], horizontal=True, key="new_video_mode")
|
|
388
|
+
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
389
|
+
if mode == "general":
|
|
390
|
+
selected = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid), key="new_video_channels_general")
|
|
391
|
+
else:
|
|
392
|
+
selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"], key="new_video_channel")
|
|
393
|
+
selected = [selected_one["id"]]
|
|
394
|
+
with st.form("new_video_form"):
|
|
395
|
+
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
396
|
+
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
397
|
+
legacy_language = st.session_state.get("video_language")
|
|
398
|
+
legacy_language_map = {
|
|
399
|
+
"Português": "36 – Português (Brasil)",
|
|
400
|
+
"English": "01 – Inglês",
|
|
401
|
+
"Español": "41 – Espanhol (LatAm)",
|
|
402
|
+
}
|
|
403
|
+
if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
|
|
404
|
+
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
405
|
+
language = st.selectbox("Idioma", VIDEO_LANGUAGE_OPTIONS, key="video_language")
|
|
406
|
+
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
407
|
+
style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
|
|
408
|
+
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
409
|
+
if submitted:
|
|
410
|
+
if not topic.strip() or not selected:
|
|
411
|
+
st.error("Informe um tópico e seleccione pelo menos um canal.")
|
|
412
|
+
else:
|
|
413
|
+
quantity = int(quantity if mode == "same_channel" else 1)
|
|
414
|
+
batch = create_batch(mode, selected, topic, quantity, {"language": language, "format": fmt, "style_wide": style})
|
|
415
|
+
tasks = create_tasks_for_batch(batch)
|
|
416
|
+
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
|
|
417
|
+
with videos_tab:
|
|
418
|
+
render_videos()
|
|
282
419
|
|
|
283
420
|
|
|
284
421
|
def render_videos():
|
|
285
|
-
st.
|
|
422
|
+
st.subheader("Vídeos e backlog")
|
|
423
|
+
st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
|
|
286
424
|
tasks = read_json("tasks.json", [])
|
|
287
425
|
if not tasks:
|
|
288
426
|
st.info("Nenhum vídeo criado.")
|
|
289
427
|
return
|
|
290
|
-
state_filter = st.selectbox("Filtrar por estado", ["Todos", "to_do", "doing", "blocked", "done", "failed", "cancelled"])
|
|
428
|
+
state_filter = st.selectbox("Filtrar por estado", ["Todos", "to_do", "doing", "blocked", "done", "failed", "cancelled"], key="videos_state_filter")
|
|
291
429
|
for task in tasks:
|
|
292
430
|
if state_filter != "Todos" and task.get("state") != state_filter:
|
|
293
431
|
continue
|
|
@@ -311,10 +449,32 @@ def render_videos():
|
|
|
311
449
|
|
|
312
450
|
def render_upload():
|
|
313
451
|
st.title("Upload")
|
|
452
|
+
settings = read_json("settings.json", {})
|
|
453
|
+
youtube = YouTubeAdapter(settings=settings)
|
|
314
454
|
tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
|
|
315
455
|
destination = st.multiselect("Destinos", ["YouTube", "TikTok"], default=["YouTube"])
|
|
456
|
+
|
|
457
|
+
if "YouTube" in destination:
|
|
458
|
+
st.markdown("**YouTube — youtube-automation-agent (principal)**")
|
|
459
|
+
st.caption("O Thunderbolt executa internamente a lógica do PublishingSchedulingAgent: OAuth, upload resumível, metadados, thumbnail e legendas. O OAuth directo é usado apenas como redundância se o caminho principal falhar.")
|
|
460
|
+
status = youtube.upload_status()
|
|
461
|
+
status_cols = st.columns(2)
|
|
462
|
+
with status_cols[0]:
|
|
463
|
+
(st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
|
|
464
|
+
if st.button("Autorizar agente YouTube", key="youtube_authorize_agent", use_container_width=True):
|
|
465
|
+
result = youtube.authorize_agent()
|
|
466
|
+
(st.success if result.ok else st.error)(result.message)
|
|
467
|
+
if result.ok:
|
|
468
|
+
st.rerun()
|
|
469
|
+
with status_cols[1]:
|
|
470
|
+
(st.info if status["fallback"].ok else st.caption)(f"Fallback OAuth: {status['fallback'].message}")
|
|
471
|
+
if st.button("Autorizar fallback OAuth", key="youtube_authorize_fallback", use_container_width=True):
|
|
472
|
+
result = youtube.authorize_fallback()
|
|
473
|
+
(st.success if result.ok else st.error)(result.message)
|
|
474
|
+
if result.ok:
|
|
475
|
+
st.rerun()
|
|
316
476
|
if "TikTok" in destination:
|
|
317
|
-
status = TikTokAdapter(
|
|
477
|
+
status = TikTokAdapter(settings).status()
|
|
318
478
|
(st.success if status.ok else st.warning)(status.message)
|
|
319
479
|
if not tasks:
|
|
320
480
|
st.info("Não há vídeos prontos para upload.")
|
|
@@ -322,14 +482,53 @@ def render_upload():
|
|
|
322
482
|
for task in tasks:
|
|
323
483
|
with st.container(border=True):
|
|
324
484
|
st.write(f"**{task.get('topic')}** — {task.get('channel_name')}")
|
|
325
|
-
|
|
485
|
+
artifacts = task.get("artifacts", {}) or {}
|
|
486
|
+
video_path = artifacts.get("video", "")
|
|
487
|
+
thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
|
|
488
|
+
captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
|
|
326
489
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
327
|
-
if
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
490
|
+
if "YouTube" in destination:
|
|
491
|
+
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
492
|
+
description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
|
|
493
|
+
tags_raw = st.text_input("Tags separadas por vírgula", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"yt_tags_{task['id']}")
|
|
494
|
+
yt_cols = st.columns(3)
|
|
495
|
+
with yt_cols[0]:
|
|
496
|
+
privacy_status = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"yt_privacy_{task['id']}")
|
|
497
|
+
with yt_cols[1]:
|
|
498
|
+
category_id = st.text_input("Category ID", value="22", key=f"yt_category_{task['id']}")
|
|
499
|
+
with yt_cols[2]:
|
|
500
|
+
language = st.text_input("Idioma", value="pt-BR", key=f"yt_language_{task['id']}")
|
|
501
|
+
if st.button("Publicar no YouTube", type="primary", key=f"upload_youtube_{task['id']}"):
|
|
502
|
+
tags = [tag.strip() for tag in tags_raw.split(",") if tag.strip()]
|
|
503
|
+
result = youtube.upload_video(
|
|
504
|
+
video_path,
|
|
505
|
+
title=title,
|
|
506
|
+
description=description,
|
|
507
|
+
tags=tags,
|
|
508
|
+
category_id=category_id,
|
|
509
|
+
language=language,
|
|
510
|
+
privacy_status=privacy_status,
|
|
511
|
+
thumbnail_path=thumbnail_path,
|
|
512
|
+
captions_path=captions_path,
|
|
513
|
+
)
|
|
514
|
+
record = {
|
|
515
|
+
"task_id": task.get("id"),
|
|
516
|
+
"destination": "YouTube",
|
|
517
|
+
"status": "published" if result.ok else "failed",
|
|
518
|
+
"message": result.message,
|
|
519
|
+
"data": result.data,
|
|
520
|
+
"created_at": now(),
|
|
521
|
+
}
|
|
522
|
+
uploads = read_json("uploads.json", [])
|
|
523
|
+
uploads.append(record)
|
|
524
|
+
write_json("uploads.json", uploads)
|
|
525
|
+
(st.success if result.ok else st.error)(result.message)
|
|
526
|
+
if result.data.get("attempts"):
|
|
527
|
+
with st.expander("Detalhes dos mecanismos de upload"):
|
|
528
|
+
st.json(result.data["attempts"])
|
|
529
|
+
if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}"):
|
|
530
|
+
result = TikTokAdapter(settings).upload_video(video_path, task.get("topic", ""))
|
|
531
|
+
(st.success if result.ok else st.warning)(result.message)
|
|
333
532
|
|
|
334
533
|
|
|
335
534
|
def render_settings():
|
|
@@ -485,6 +684,77 @@ def render_settings():
|
|
|
485
684
|
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
486
685
|
|
|
487
686
|
|
|
687
|
+
def render_mcp():
|
|
688
|
+
st.title("MCP")
|
|
689
|
+
st.caption("Clientes opcionais para serviços externos. Os repositórios não são instalados nem incluídos no pacote Thunderbolt.")
|
|
690
|
+
st.info("Activar uma integração guarda apenas a preferência local. O Thunderbolt não inicia processos externos automaticamente; a detecção verifica passivamente se existe um serviço já disponível na porta configurada.")
|
|
691
|
+
|
|
692
|
+
integrations = load_integrations()
|
|
693
|
+
for integration in integrations:
|
|
694
|
+
integration_id = integration["id"]
|
|
695
|
+
with st.container(border=True):
|
|
696
|
+
header_cols = st.columns([2.6, 1.15, 1.65, 1.2])
|
|
697
|
+
with header_cols[0]:
|
|
698
|
+
st.write(f"**{integration['name']}**")
|
|
699
|
+
st.caption(f"{integration['protocol']} · {integration['description']}")
|
|
700
|
+
st.markdown(f"[Abrir repositório oficial]({integration['repository']})")
|
|
701
|
+
with header_cols[1]:
|
|
702
|
+
port = st.number_input(
|
|
703
|
+
"Porta",
|
|
704
|
+
min_value=1,
|
|
705
|
+
max_value=65535,
|
|
706
|
+
value=int(integration.get("port", 8000)),
|
|
707
|
+
step=1,
|
|
708
|
+
key=f"mcp_port_{integration_id}",
|
|
709
|
+
)
|
|
710
|
+
with header_cols[2]:
|
|
711
|
+
status = detect_local_service({**integration, "port": port})
|
|
712
|
+
if status["available"]:
|
|
713
|
+
st.success("Disponível")
|
|
714
|
+
else:
|
|
715
|
+
st.caption("Não detectado")
|
|
716
|
+
st.caption(status["message"])
|
|
717
|
+
with header_cols[3]:
|
|
718
|
+
active = st.toggle("Activo", value=bool(integration.get("active", False)), key=f"mcp_active_{integration_id}")
|
|
719
|
+
if active != bool(integration.get("active", False)):
|
|
720
|
+
update_integration(integration_id, active=active)
|
|
721
|
+
st.rerun()
|
|
722
|
+
|
|
723
|
+
st.caption(integration.get("endpoint_note", "Porta editável para o serviço local."))
|
|
724
|
+
if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", use_container_width=True):
|
|
725
|
+
update_integration(integration_id, port=int(port))
|
|
726
|
+
st.success(f"Porta de {integration['name']} guardada: {int(port)}")
|
|
727
|
+
st.rerun()
|
|
728
|
+
|
|
729
|
+
st.divider()
|
|
730
|
+
st.subheader("Skill MoneyPrinterTurbo")
|
|
731
|
+
st.caption("A skill anexada pode ser guardada na pasta local do Thunderbolt ou descarregada como ficheiro Markdown. Nenhum dos quatro repositórios externos é copiado para o pacote.")
|
|
732
|
+
skill_cols = st.columns(2)
|
|
733
|
+
with skill_cols[0]:
|
|
734
|
+
if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
|
|
735
|
+
try:
|
|
736
|
+
destination = install_skill_locally()
|
|
737
|
+
st.success(f"Skill guardada em `{destination}`")
|
|
738
|
+
except (FileNotFoundError, OSError) as exc:
|
|
739
|
+
st.error(f"Não foi possível guardar a skill: {exc}")
|
|
740
|
+
with skill_cols[1]:
|
|
741
|
+
try:
|
|
742
|
+
skill_data = read_packaged_skill()
|
|
743
|
+
except FileNotFoundError:
|
|
744
|
+
skill_data = None
|
|
745
|
+
if skill_data is not None:
|
|
746
|
+
st.download_button(
|
|
747
|
+
"Descarregar skill .md",
|
|
748
|
+
data=skill_data,
|
|
749
|
+
file_name="moneyprinterturbo-video.md",
|
|
750
|
+
mime="text/markdown",
|
|
751
|
+
use_container_width=True,
|
|
752
|
+
key="mcp_download_mpt_skill",
|
|
753
|
+
)
|
|
754
|
+
else:
|
|
755
|
+
st.warning("A skill ainda não está disponível nesta instalação.")
|
|
756
|
+
|
|
757
|
+
|
|
488
758
|
def render_metadata_cleaner():
|
|
489
759
|
st.title("Limpador de metadado")
|
|
490
760
|
st.caption("Limpeza e edição de metadados para vídeos de terceiros que já estão prontos.")
|
|
@@ -620,12 +890,15 @@ def main():
|
|
|
620
890
|
("Blueprints", ":material/library_books:", "Blueprints"),
|
|
621
891
|
("Canais", ":material/ondemand_video:", "Canais"),
|
|
622
892
|
("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
|
|
623
|
-
("Vídeos", ":material/video_library:", "Vídeos"),
|
|
624
893
|
("Upload", ":material/cloud_upload:", "Upload"),
|
|
894
|
+
("MCP", ":material/hub:", "MCP"),
|
|
625
895
|
("Limpador de metadado", ":material/edit_note:", "Limpador de metadado"),
|
|
626
896
|
("Configurações", ":material/settings:", "Configurações"),
|
|
627
897
|
]
|
|
628
898
|
current_page = st.session_state.get("page", "Dashboard")
|
|
899
|
+
if current_page == "Vídeos":
|
|
900
|
+
current_page = "Novo vídeo"
|
|
901
|
+
st.session_state["page"] = current_page
|
|
629
902
|
with st.sidebar:
|
|
630
903
|
version_markup = f'<span class="tb-brand-version">{APP_VERSION}</span>' if APP_VERSION else ""
|
|
631
904
|
st.markdown(f'<div class="tb-brand"><span class="tb-brand-name">Thunderbolt</span>{version_markup}</div>', unsafe_allow_html=True)
|
|
@@ -639,8 +912,8 @@ def main():
|
|
|
639
912
|
"Blueprints": render_blueprints,
|
|
640
913
|
"Canais": render_channels,
|
|
641
914
|
"Novo vídeo": render_new_video,
|
|
642
|
-
"Vídeos": render_videos,
|
|
643
915
|
"Upload": render_upload,
|
|
916
|
+
"MCP": render_mcp,
|
|
644
917
|
"Limpador de metadado": render_metadata_cleaner,
|
|
645
918
|
"Configurações": render_settings,
|
|
646
919
|
}
|