@danhachuel/thunderbolt 0.2.39 → 0.2.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.2.39
5
+ > **Versão deste manual:** 0.2.41
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.39 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.41 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.39 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.41 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.
@@ -379,7 +379,7 @@ Após iniciar a aplicação, valide o seguinte percurso:
379
379
  7. **Criação de Vídeos > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba. Confirme também que **Criação de Músicas** mostra o mesmo fluxo com título próprio.
380
380
  8. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
381
381
  9. **Edição > Limpador de Metadados:** 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 das páginas de criação não são aceites nesta área.
382
- 10. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
382
+ 10. **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 e OAuth Client Secret próprios; o e-mail identifica a conta e não é usado para ler a caixa Gmail.
383
383
  11. **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`.
384
384
  12. **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.
385
385
  13. **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.
@@ -388,6 +388,14 @@ Após iniciar a aplicação, valide o seguinte percurso:
388
388
  16. **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.
389
389
  17. **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.
390
390
 
391
+ ## Canais em lote por conta Google/YouTube
392
+
393
+ A subaba **Canais em lote gmail**, dentro de **Canais**, não lê e-mails, mensagens, contactos ou a caixa Gmail. O nome identifica a conta Google/Gmail que gere os canais YouTube. Configure cada conta em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, preenchendo e-mail, OAuth Client ID e OAuth Client Secret.
394
+
395
+ Na subaba, seleccione a conta e clique em **Autorizar conta Google**. A autorização abre o browser do sistema e guarda um refresh token separado para essa conta. Depois clique em **Listar canais desta conta**. O Thunderbolt chama a YouTube Data API com `channels.list`, `mine=true`, `part=snippet,contentDetails,statistics`, suporta páginas sucessivas e apresenta os canais encontrados para selecção.
396
+
397
+ Escolha os canais e os defaults de Blueprint, voz, idioma e estilo wide, depois clique em **Cadastrar canais seleccionados**. A importação usa `youtube_channel_id` para não duplicar canais já cadastrados, preserva os registos existentes e identifica a origem como `youtube_data_api_oauth_mine`. A API Key global não substitui OAuth neste fluxo; permanece reservada para consultas públicas/métricas.
398
+
391
399
  ## Niche Finder Kaggle e Niche Finder Apify
392
400
 
393
401
  A página **Niche Finder Kaggle** integra a lógica adaptada do projecto open source [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder), cujo projecto original usa K-Means e FP-Growth sobre o dataset público [Trending Youtube Video Statistics (113 Countries)](https://www.kaggle.com/datasets/asaniczka/trending-youtube-videos-113-countries). No Thunderbolt não existe Flask, rota HTTP adicional, template HTML, JavaScript D3 ou segundo processo; toda a análise é síncrona no Streamlit.
@@ -396,7 +404,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
396
404
 
397
405
  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.
398
406
 
399
- 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.39 install`; o instalador detecta e reutiliza o que já estiver válido.
407
+ 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.41 install`; o instalador detecta e reutiliza o que já estiver válido.
400
408
 
401
409
  ### Niche Finder Apify
402
410
 
@@ -408,7 +416,13 @@ O menu **Models AI** foi adicionado abaixo de **Edição**. As abas **Personagen
408
416
 
409
417
  ## Edição: Limpador de Metadados, Cortes e Editor Python
410
418
 
411
- A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**. As abas **Cortes** e **Editor Python** estão visíveis na mesma ordem e mostram apenas uma mensagem de reserva para desenvolvimento futuro; não executam operações.
419
+ A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**. A aba **Cortes** permanece reservada e não executa operações. A aba **Editor Python** é funcional e permite escolher vídeos gerados, indicar uma pasta local ou fazer upload manual; as operações são manuais e criam cópias sem alterar os originais.
420
+
421
+ ## Editor Python baseado no PYEdit
422
+
423
+ O **Editor Python** adapta o recorte do [PYEdit](https://github.com/Congren/PYEdit) ao Thunderbolt. Na subaba **Vídeos**, pode seleccionar um vídeo já gerado e registado nos artefactos da pipeline, indicar uma pasta local de vídeos ou fazer upload manual. As operações disponíveis são cortar trecho, remover áudio, extrair áudio, substituir áudio, alterar velocidade e redimensionar vídeo.
424
+
425
+ Cada operação usa FFmpeg local, preserva o original, cria a saída em `storage/python_editor/outputs/`, guarda o histórico em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. Na subaba **Código Python**, pode criar e guardar scripts em `storage/python_editor/scripts/` ou carregar scripts existentes. Por segurança, a UI não executa código Python e não executa scripts automaticamente.
412
426
 
413
427
  ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
414
428
 
package/README.md CHANGED
@@ -14,20 +14,20 @@ 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, Data API opcional 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 |
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 |
21
21
  | Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
22
- | Edição | Menu expansível abaixo de Automação com Limpador de Metadados funcional, Cortes reservado e Editor Python reservado |
22
+ | Edição | Menu expansível abaixo de Automação com Limpador de Metadados funcional, Cortes reservado e Editor Python inspirado no PYEdit para vídeos e scripts locais |
23
23
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, TikTok, Instagram e Facebook Pages no front end |
24
24
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
25
- | Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
25
+ | Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID e Client Secret por conta, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
26
26
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
27
27
 
28
28
  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.
29
29
 
30
- ## Navegação da UI 0.2.39
30
+ ## Navegação da UI 0.2.41
31
31
 
32
32
  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.
33
33
 
@@ -116,9 +116,9 @@ thunderbolt
116
116
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
117
117
 
118
118
  ```powershell
119
- npx.cmd --yes @danhachuel/thunderbolt@0.2.39 install
120
- npx.cmd --yes @danhachuel/thunderbolt@0.2.39 doctor
121
- npx.cmd --yes @danhachuel/thunderbolt@0.2.39
119
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.41 install
120
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.41 doctor
121
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.41
122
122
  ```
123
123
 
124
124
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -142,7 +142,15 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
142
142
 
143
143
  A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
144
144
 
145
- **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.36 install`; o instalador detecta e reutiliza componentes já válidos.
145
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.41 install`; o instalador detecta e reutiliza componentes já válidos.
146
+
147
+ ## Editor Python baseado no PYEdit
148
+
149
+ O **Editor Python** combina o recorte seguro do [PYEdit](https://github.com/Congren/PYEdit) com o armazenamento local do Thunderbolt. Na subaba **Vídeos**, pode escolher um vídeo já gerado e registado nos artefactos da pipeline, indicar qualquer pasta local de vídeos ou fazer upload manual. A página não inicia nenhuma edição ao ser aberta.
150
+
151
+ As operações disponíveis são cortar trecho, remover áudio, extrair áudio, substituir áudio, alterar velocidade e redimensionar vídeo. Cada operação cria uma cópia em `storage/python_editor/outputs/`, preserva o original, regista um histórico separado em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. As operações usam FFmpeg local, aproveitando o caminho configurado em **Configurações Técnicas** ou o fallback `imageio-ffmpeg`.
152
+
153
+ Na subaba **Código Python**, pode criar e guardar scripts em `storage/python_editor/scripts/` ou carregar scripts existentes. Por segurança, a UI não executa código Python, não possui botão de execução e não executa scripts automaticamente.
146
154
 
147
155
  ## Armazenamento local
148
156
 
package/app/main.py CHANGED
@@ -4,6 +4,7 @@ import json
4
4
  import re
5
5
  from datetime import date, datetime
6
6
  import sys
7
+ import uuid
7
8
  from pathlib import Path
8
9
 
9
10
  import requests
@@ -19,19 +20,21 @@ except (OSError, json.JSONDecodeError):
19
20
 
20
21
  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
21
22
  from hermes_ui.automation_worker import load_worker_status
22
- from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
23
+ from hermes_ui.storage import BLUEPRINTS, STORAGE, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
23
24
  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
24
25
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
25
26
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
26
27
  from app.modules.niche_finder.summarizer import summarize_items
27
28
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
28
29
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
30
+ from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEditorError, change_speed, editor_manifest, extract_audio, list_edit_records as list_python_editor_records, list_generated_videos, list_scripts, list_video_files, read_script, remove_audio, replace_audio, resize_video, save_edit_record as save_python_editor_record, save_script, store_uploaded_asset, trim_video
29
31
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
30
32
  from hermes_ui.mcp_server import server_status, start_server, stop_server
31
33
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
32
34
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
33
35
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
34
36
  from integrations.youtube_direct_upload import YouTubeDirectUploader
37
+ 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
35
38
  from integrations.local_runtime import MoneyPrinterRuntime
36
39
  from integrations.moneyprinter_config import sync_moneyprinter_config
37
40
 
@@ -338,7 +341,7 @@ def render_channels():
338
341
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
339
342
  settings = read_json("settings.json", {})
340
343
  youtube = YouTubeAdapter(settings=settings)
341
- import_tab, manual_tab = st.tabs(["Importar do YouTube", "Cadastro manual"])
344
+ import_tab, batch_tab, manual_tab = st.tabs(["Importar do YouTube", "Canais em lote gmail", "Cadastro manual"])
342
345
 
343
346
  with import_tab:
344
347
  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.")
@@ -422,6 +425,96 @@ def render_channels():
422
425
  else:
423
426
  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.")
424
427
 
428
+ with batch_tab:
429
+ st.caption("Esta subaba usa a conta Google/YouTube seleccionada para listar os canais que ela gere. Não lê a caixa Gmail e não usa e-mails como pesquisa pública.")
430
+ accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
431
+ if not accounts:
432
+ st.info("Configure primeiro uma conta em Configurações > Configurações Técnicas > Contas Google/YouTube para canais em lote.")
433
+ else:
434
+ account_ids = [str(account["id"]) for account in accounts]
435
+ account_labels = {str(account["id"]): f"{account.get('email', 'Conta Google')} — {account.get('label', 'Canais YouTube')}" for account in accounts}
436
+ default_account_id = str(settings.get("youtube_batch_selected_account_id") or account_ids[0])
437
+ if default_account_id not in account_ids:
438
+ default_account_id = account_ids[0]
439
+ selected_account_id = st.selectbox("Conta Google/YouTube", account_ids, index=account_ids.index(default_account_id), format_func=lambda item: account_labels.get(item, item), key="batch_selected_account_id")
440
+ selected_account = next(account for account in accounts if str(account["id"]) == selected_account_id)
441
+ account_status = youtube_batch_account_status(selected_account, STORAGE)
442
+ (st.success if account_status.ok else st.warning)(account_status.message)
443
+ auth_cols = st.columns([1, 2])
444
+ with auth_cols[0]:
445
+ if st.button("Autorizar conta Google", type="secondary", use_container_width=True, key="batch_authorize_account"):
446
+ result = authorize_youtube_batch_account(selected_account, STORAGE)
447
+ (st.success if result.ok else st.error)(result.message)
448
+ if result.ok:
449
+ st.rerun()
450
+ with auth_cols[1]:
451
+ st.caption("A autorização é feita no browser do sistema e fica guardada separadamente para esta conta.")
452
+ batch_key = f"youtube_batch_channels_{selected_account_id}"
453
+ if st.button("Listar canais desta conta", type="primary", use_container_width=True, key="batch_list_channels"):
454
+ result = list_youtube_batch_channels(selected_account, STORAGE)
455
+ st.session_state[batch_key] = result.data.get("channels", []) if result.ok else []
456
+ st.session_state[f"{batch_key}_message"] = result.message
457
+ st.session_state[f"{batch_key}_ok"] = result.ok
458
+ if st.session_state.get(f"{batch_key}_message"):
459
+ (st.success if st.session_state.get(f"{batch_key}_ok") else st.error)(st.session_state[f"{batch_key}_message"])
460
+ listed_channels = st.session_state.get(batch_key, [])
461
+ if listed_channels:
462
+ channel_by_id = {str(channel.get("youtube_channel_id")): channel for channel in listed_channels if channel.get("youtube_channel_id")}
463
+ channel_labels = {channel_id: f"{channel.get('name', channel_id)} — {channel.get('handle') or channel_id}" for channel_id, channel in channel_by_id.items()}
464
+ existing_channels = read_json("channels.json", [])
465
+ existing_ids = {str(channel.get("youtube_channel_id")) for channel in existing_channels if channel.get("youtube_channel_id")}
466
+ existing_count = sum(1 for channel_id in channel_by_id if channel_id in existing_ids)
467
+ st.caption(f"{len(listed_channels)} canal(is) listado(s); {existing_count} já cadastrado(s). A API devolveu a lista da conta autenticada, não uma caixa Gmail.")
468
+ blueprint_items = blueprint_catalog()
469
+ blueprint_ids = [item[0] for item in blueprint_items]
470
+ blueprint_labels = {item[0]: item[1] for item in blueprint_items}
471
+ voice_options = voice_catalog()
472
+ with st.form("batch_channel_import_form"):
473
+ selected_channel_ids = st.multiselect("Canais a cadastrar", list(channel_by_id), default=list(channel_by_id), format_func=lambda item: channel_labels.get(item, item), key="batch_channel_ids")
474
+ defaults_cols = st.columns(4)
475
+ with defaults_cols[0]:
476
+ batch_blueprint = st.selectbox("Blueprint padrão", blueprint_ids, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="batch_channel_blueprint")
477
+ with defaults_cols[1]:
478
+ batch_voice = st.selectbox("Voz padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="batch_channel_voice")
479
+ with defaults_cols[2]:
480
+ batch_language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], key="batch_channel_language")
481
+ with defaults_cols[3]:
482
+ batch_style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], key="batch_channel_style")
483
+ import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", use_container_width=True)
484
+ if import_selected:
485
+ created_names = []
486
+ skipped_names = []
487
+ failed_names = []
488
+ style_value = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(batch_style, batch_style)
489
+ for channel_id in selected_channel_ids:
490
+ data = channel_by_id[channel_id]
491
+ if channel_id in existing_ids:
492
+ skipped_names.append(data.get("name", channel_id))
493
+ continue
494
+ try:
495
+ create_channel(data.get("name", channel_id), data.get("url", ""), {
496
+ **data,
497
+ "youtube_channel_id": channel_id,
498
+ "google_account_id": str(selected_account.get("id", "")),
499
+ "google_account_email": str(selected_account.get("email", "")),
500
+ "blueprint_id": batch_blueprint.strip(),
501
+ "default_blueprint_id": batch_blueprint.strip(),
502
+ "default_voice": batch_voice.strip(),
503
+ "voice": batch_voice.strip(),
504
+ "language": batch_language,
505
+ "style_wide": style_value,
506
+ "last_youtube_sync": now(),
507
+ })
508
+ created_names.append(data.get("name", channel_id))
509
+ except Exception as exc:
510
+ failed_names.append(f"{data.get('name', channel_id)}: {exc}")
511
+ if created_names:
512
+ st.success(f"Canais cadastrados: {', '.join(created_names)}")
513
+ if skipped_names:
514
+ st.info(f"Já existentes e não duplicados: {', '.join(skipped_names)}")
515
+ if failed_names:
516
+ st.error(f"Falhas: {'; '.join(failed_names)}")
517
+
425
518
  with manual_tab:
426
519
  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.")
427
520
  with st.form("channel_manual_form"):
@@ -888,6 +981,134 @@ def render_edit_placeholder(page_title: str, description: str):
888
981
  st.info("Esta aba está reservada para desenvolvimento futuro e ainda não executa nenhuma operação.")
889
982
 
890
983
 
984
+ def render_python_editor():
985
+ st.title("Editor Python")
986
+ st.caption("Editor local inspirado no PYEdit para scripts Python e edição manual de vídeos do Thunderbolt.")
987
+ st.info("Escolha um vídeo gerado, indique uma pasta local ou faça upload de um vídeo. Nenhum código Python é executado nesta aba; as operações de vídeo só começam depois de clicar no botão da operação.")
988
+
989
+ video_tab, code_tab = st.tabs(["Vídeos", "Código Python"])
990
+ with video_tab:
991
+ st.subheader("Escolher vídeo")
992
+ source_mode = st.radio("Origem", ["Vídeos gerados", "Pasta local", "Upload manual"], horizontal=True, key="python_editor_source_mode")
993
+ source_path = None
994
+ if source_mode == "Vídeos gerados":
995
+ tasks = read_json("tasks.json", [])
996
+ generated_paths = list_generated_videos(tasks)
997
+ if not generated_paths:
998
+ st.info("Ainda não existem vídeos gerados com caminho registado nos artefactos da pipeline.")
999
+ else:
1000
+ labels = [f"{path.name} — {path}" for path in generated_paths]
1001
+ selected_index = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: labels[index], key="python_editor_generated_index")
1002
+ source_path = generated_paths[selected_index]
1003
+ elif source_mode == "Pasta local":
1004
+ default_folder = str(STORAGE / "videos")
1005
+ folder_value = st.text_input("Pasta de vídeos", value=st.session_state.get("python_editor_folder", default_folder), key="python_editor_folder")
1006
+ folder_paths = list_video_files(folder_value)
1007
+ if not folder_paths:
1008
+ st.info("Não foram encontrados vídeos nessa pasta. Pode indicar a pasta de vídeos gerados ou qualquer outra pasta local.")
1009
+ else:
1010
+ labels = [f"{path.name} — {path}" for path in folder_paths]
1011
+ selected_index = st.selectbox("Vídeo da pasta", range(len(folder_paths)), format_func=lambda index: labels[index], key="python_editor_folder_index")
1012
+ source_path = folder_paths[selected_index]
1013
+ else:
1014
+ uploaded_video = st.file_uploader("Subir vídeo para editar", type=sorted(extension.lstrip(".") for extension in VIDEO_EXTENSIONS), key="python_editor_video_upload")
1015
+ if uploaded_video is not None:
1016
+ try:
1017
+ source_path = store_uploaded_asset(uploaded_video.name, uploaded_video.getvalue())
1018
+ except PythonEditorError as exc:
1019
+ st.error(str(exc))
1020
+
1021
+ if source_path and source_path.is_file():
1022
+ st.session_state["python_editor_source_path"] = str(source_path)
1023
+ st.caption(f"Fonte seleccionada: `{source_path}` · {source_path.stat().st_size / (1024 * 1024):.2f} MB")
1024
+ st.video(str(source_path))
1025
+ st.divider()
1026
+ st.subheader("Operação PYEdit")
1027
+ operation_options = ["Cortar trecho", "Remover áudio", "Extrair áudio", "Substituir áudio", "Alterar velocidade", "Redimensionar vídeo"]
1028
+ operation = st.selectbox("Operação", operation_options, key="python_editor_operation")
1029
+ with st.form("python_editor_video_form", clear_on_submit=False):
1030
+ start_seconds = end_seconds = speed = None
1031
+ width = height = None
1032
+ replacement_audio = None
1033
+ if operation == "Cortar trecho":
1034
+ op_cols = st.columns(2)
1035
+ with op_cols[0]:
1036
+ start_seconds = st.number_input("Início (segundos)", min_value=0.0, value=0.0, step=0.5, key="python_editor_trim_start")
1037
+ with op_cols[1]:
1038
+ end_seconds = st.number_input("Fim (segundos)", min_value=0.5, value=10.0, step=0.5, key="python_editor_trim_end")
1039
+ elif operation == "Substituir áudio":
1040
+ replacement_audio = st.file_uploader("Novo áudio", type=sorted(extension.lstrip(".") for extension in AUDIO_EXTENSIONS), key="python_editor_audio_upload")
1041
+ st.caption("O áudio substitui a faixa original e a duração final fica limitada ao menor fluxo.")
1042
+ elif operation == "Alterar velocidade":
1043
+ speed = st.number_input("Velocidade", min_value=0.25, max_value=4.0, value=1.0, step=0.25, key="python_editor_speed")
1044
+ elif operation == "Redimensionar vídeo":
1045
+ resize_cols = st.columns(2)
1046
+ with resize_cols[0]:
1047
+ width = st.number_input("Largura", min_value=16, max_value=7680, value=1280, step=2, key="python_editor_width")
1048
+ with resize_cols[1]:
1049
+ height = st.number_input("Altura", min_value=16, max_value=7680, value=720, step=2, key="python_editor_height")
1050
+ apply_operation = st.form_submit_button(f"Aplicar: {operation}", type="primary", use_container_width=True)
1051
+ if apply_operation:
1052
+ try:
1053
+ ffmpeg_path = read_json("settings.json", {}).get("ffmpeg_path", "")
1054
+ if operation == "Cortar trecho":
1055
+ output, run_info = trim_video(source_path, float(start_seconds), float(end_seconds), ffmpeg_path=ffmpeg_path)
1056
+ elif operation == "Remover áudio":
1057
+ output, run_info = remove_audio(source_path, ffmpeg_path=ffmpeg_path)
1058
+ elif operation == "Extrair áudio":
1059
+ output, run_info = extract_audio(source_path, ffmpeg_path=ffmpeg_path)
1060
+ elif operation == "Substituir áudio":
1061
+ if replacement_audio is None:
1062
+ raise PythonEditorError("Seleccione o novo áudio antes de aplicar a operação.")
1063
+ audio_path = store_uploaded_asset(replacement_audio.name, replacement_audio.getvalue(), audio=True)
1064
+ output, run_info = replace_audio(source_path, audio_path, ffmpeg_path=ffmpeg_path)
1065
+ elif operation == "Alterar velocidade":
1066
+ output, run_info = change_speed(source_path, float(speed), ffmpeg_path=ffmpeg_path)
1067
+ else:
1068
+ output, run_info = resize_video(source_path, int(width), int(height), ffmpeg_path=ffmpeg_path)
1069
+ record = save_python_editor_record(source_path, output, run_info)
1070
+ st.session_state["python_editor_last_record"] = record
1071
+ st.success(f"Operação concluída: {output.name}")
1072
+ if output.suffix.lower() in AUDIO_EXTENSIONS:
1073
+ st.audio(output.read_bytes(), format="audio/mpeg")
1074
+ else:
1075
+ st.video(str(output))
1076
+ st.download_button("Descarregar resultado", data=output.read_bytes(), file_name=output.name, mime="video/mp4" if output.suffix.lower() in VIDEO_EXTENSIONS else "audio/mpeg", key=f"python_editor_download_{record['id']}")
1077
+ st.download_button("Descarregar manifesto JSON", data=editor_manifest(record), file_name=f"{output.stem}.json", mime="application/json", key=f"python_editor_manifest_{record['id']}")
1078
+ except (PythonEditorError, OSError, ValueError) as exc:
1079
+ st.error(str(exc))
1080
+ else:
1081
+ st.caption("Seleccione uma fonte de vídeo para activar as operações inspiradas no PYEdit.")
1082
+
1083
+ records = list_python_editor_records()
1084
+ if records:
1085
+ with st.expander("Histórico do Editor Python"):
1086
+ st.dataframe([{key: record.get(key, "") for key in ("created_at", "operation", "source_name", "output_name")} for record in records[:20]], use_container_width=True, hide_index=True)
1087
+
1088
+ with code_tab:
1089
+ st.subheader("Scripts Python locais")
1090
+ st.warning("Por segurança, o Editor Python guarda e edita scripts, mas não oferece execução de código dentro da UI.")
1091
+ scripts = list_scripts()
1092
+ script_options = ["Novo script"] + [str(path.name) for path in scripts]
1093
+ selected_script = st.selectbox("Script", script_options, key="python_editor_script_name")
1094
+ if selected_script != "Novo script":
1095
+ selected_path = next((path for path in scripts if path.name == selected_script), None)
1096
+ if selected_path and st.button("Carregar script", key="python_editor_load_script"):
1097
+ st.session_state["python_editor_code"] = read_script(selected_path)
1098
+ st.rerun()
1099
+ with st.form("python_editor_code_form", clear_on_submit=False):
1100
+ script_name = st.text_input("Nome do script", value="script.py" if selected_script == "Novo script" else selected_script, key="python_editor_code_name")
1101
+ script_content = st.text_area("Código Python", value=st.session_state.get("python_editor_code", ""), height=420, key="python_editor_code_text")
1102
+ save_code = st.form_submit_button("Guardar script localmente", type="primary")
1103
+ if save_code:
1104
+ try:
1105
+ path = save_script(script_name, script_content)
1106
+ st.session_state["python_editor_code"] = script_content
1107
+ st.success(f"Script guardado em `{path}`.")
1108
+ except PythonEditorError as exc:
1109
+ st.error(str(exc))
1110
+
1111
+
891
1112
  def render_videos():
892
1113
  st.subheader("Vídeos e backlog")
893
1114
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
@@ -1158,6 +1379,83 @@ def render_settings():
1158
1379
  key=f"settings_{key}",
1159
1380
  )
1160
1381
 
1382
+ st.subheader("Contas Google/YouTube — canais em lote")
1383
+ st.caption("Cada registo representa uma conta Google que pode gerir vários canais do YouTube. O e-mail identifica a conta; esta área não lê a caixa Gmail. Cada conta tem Client ID, Client Secret e token OAuth próprios.")
1384
+ batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1385
+ for batch_account in batch_accounts:
1386
+ account_id = str(batch_account["id"])
1387
+ with st.container(border=True):
1388
+ with st.form(f"batch_account_form_{account_id}"):
1389
+ account_cols = st.columns(4)
1390
+ with account_cols[0]:
1391
+ account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
1392
+ with account_cols[1]:
1393
+ account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
1394
+ with account_cols[2]:
1395
+ account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1396
+ with account_cols[3]:
1397
+ account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
1398
+ save_account = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
1399
+ account_status = youtube_batch_account_status(batch_account, STORAGE)
1400
+ status_cols = st.columns([2, 1, 1])
1401
+ with status_cols[0]:
1402
+ (st.success if account_status.ok else st.warning)(account_status.message)
1403
+ with status_cols[1]:
1404
+ if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
1405
+ result = authorize_youtube_batch_account(batch_account, STORAGE)
1406
+ (st.success if result.ok else st.error)(result.message)
1407
+ if result.ok:
1408
+ st.rerun()
1409
+ with status_cols[2]:
1410
+ if st.button("Remover conta", key=f"batch_remove_settings_{account_id}", use_container_width=True):
1411
+ delete_youtube_batch_token(batch_account, STORAGE)
1412
+ remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
1413
+ settings["youtube_batch_accounts"] = remaining_accounts
1414
+ if settings.get("youtube_batch_selected_account_id") == account_id:
1415
+ settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
1416
+ write_json("settings.json", settings)
1417
+ st.rerun()
1418
+ if save_account:
1419
+ if "@" not in account_email.strip():
1420
+ st.error("Informe um e-mail Google válido.")
1421
+ elif not account_client_id.strip() or not account_client_secret.strip():
1422
+ st.error("Informe o Client ID e o Client Secret desta conta.")
1423
+ else:
1424
+ for existing in batch_accounts:
1425
+ if str(existing.get("id")) == account_id:
1426
+ credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
1427
+ if credentials_changed:
1428
+ delete_youtube_batch_token(existing, STORAGE)
1429
+ existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip()})
1430
+ settings["youtube_batch_accounts"] = batch_accounts
1431
+ write_json("settings.json", settings)
1432
+ st.success("Conta Google/YouTube guardada.")
1433
+ st.rerun()
1434
+ with st.form("add_batch_account_form"):
1435
+ add_cols = st.columns(4)
1436
+ with add_cols[0]:
1437
+ new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
1438
+ with add_cols[1]:
1439
+ new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
1440
+ with add_cols[2]:
1441
+ new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
1442
+ with add_cols[3]:
1443
+ new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
1444
+ add_account = st.form_submit_button("Adicionar conta Google/YouTube", use_container_width=True)
1445
+ if add_account:
1446
+ if "@" not in new_account_email.strip():
1447
+ st.error("Informe um e-mail Google válido.")
1448
+ elif not new_account_client_id.strip() or not new_account_client_secret.strip():
1449
+ st.error("Informe o Client ID e o Client Secret da nova conta.")
1450
+ else:
1451
+ new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip()}
1452
+ batch_accounts.append(new_account)
1453
+ settings["youtube_batch_accounts"] = batch_accounts
1454
+ settings["youtube_batch_selected_account_id"] = new_account["id"]
1455
+ write_json("settings.json", settings)
1456
+ st.success(f"Conta {new_account['email']} adicionada.")
1457
+ st.rerun()
1458
+
1161
1459
  with st.form("settings_form"):
1162
1460
  st.subheader("Execução local")
1163
1461
  port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
@@ -1735,7 +2033,7 @@ def main():
1735
2033
  "Edição": lambda: render_edit_placeholder("Edição", "Seleccione uma das abas de edição no menu expansível."),
1736
2034
  "Limpador de Metadados": render_metadata_cleaner,
1737
2035
  "Cortes": lambda: render_edit_placeholder("Cortes", "Área reservada para a futura funcionalidade de cortes de vídeo."),
1738
- "Editor Python": lambda: render_edit_placeholder("Editor Python", "Área reservada para o futuro editor Python."),
2036
+ "Editor Python": render_python_editor,
1739
2037
  "Models AI": lambda: render_edit_placeholder("Models AI", "Seleccione uma das abas Models AI no menu expansível."),
1740
2038
  "Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
1741
2039
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
@@ -22,6 +22,9 @@ def make_id(prefix: str) -> str:
22
22
  def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = None) -> dict[str, Any]:
23
23
  channel = {
24
24
  "id": make_id("channel"),
25
+ "youtube_channel_id": "",
26
+ "google_account_id": "",
27
+ "google_account_email": "",
25
28
  "name": name.strip(),
26
29
  "url": url.strip(),
27
30
  "handle": "",
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import unicodedata
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import storage
14
+ from .metadata_cleaner import VIDEO_EXTENSIONS as METADATA_VIDEO_EXTENSIONS
15
+ from .metadata_cleaner import _resolve_ffmpeg
16
+
17
+ VIDEO_EXTENSIONS = {f".{extension.lower().lstrip('.')}" for extension in METADATA_VIDEO_EXTENSIONS}
18
+ AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
19
+
20
+
21
+ class PythonEditorError(RuntimeError):
22
+ """Raised when a controlled local editing operation cannot be completed."""
23
+
24
+
25
+ def _root() -> Path:
26
+ return storage.STORAGE / "python_editor"
27
+
28
+
29
+ def _outputs() -> Path:
30
+ path = _root() / "outputs"
31
+ path.mkdir(parents=True, exist_ok=True)
32
+ return path
33
+
34
+
35
+ def _scripts() -> Path:
36
+ path = _root() / "scripts"
37
+ path.mkdir(parents=True, exist_ok=True)
38
+ return path
39
+
40
+
41
+ def _now() -> str:
42
+ return datetime.now(timezone.utc).isoformat()
43
+
44
+
45
+ def _safe_filename(name: str, fallback: str = "video") -> str:
46
+ normalized = unicodedata.normalize("NFKD", Path(name).name)
47
+ ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
48
+ ascii_name = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_name).strip(".-")
49
+ return ascii_name or fallback
50
+
51
+
52
+ def _output_path(source: Path, operation: str, suffix: str | None = None) -> Path:
53
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
54
+ source_name = _safe_filename(source.stem, "video")
55
+ extension = suffix or source.suffix or ".mp4"
56
+ if not extension.startswith("."):
57
+ extension = f".{extension}"
58
+ return _outputs() / f"{operation}-{stamp}-{source_name}{extension.lower()}"
59
+
60
+
61
+ def _validate_video(source: Path) -> Path:
62
+ candidate = Path(source).expanduser()
63
+ if not candidate.is_file():
64
+ raise PythonEditorError("O vídeo seleccionado não existe ou não é um ficheiro.")
65
+ if candidate.suffix.lower() not in VIDEO_EXTENSIONS:
66
+ raise PythonEditorError("O ficheiro seleccionado não tem uma extensão de vídeo suportada.")
67
+ return candidate.resolve()
68
+
69
+
70
+ def _validate_audio(source: Path) -> Path:
71
+ candidate = Path(source).expanduser()
72
+ if not candidate.is_file():
73
+ raise PythonEditorError("O áudio seleccionado não existe ou não é um ficheiro.")
74
+ if candidate.suffix.lower() not in AUDIO_EXTENSIONS:
75
+ raise PythonEditorError("O ficheiro seleccionado não tem uma extensão de áudio suportada.")
76
+ return candidate.resolve()
77
+
78
+
79
+ def list_video_files(folder: str | Path) -> list[Path]:
80
+ candidate = Path(folder).expanduser()
81
+ if not candidate.exists():
82
+ return []
83
+ if candidate.is_file():
84
+ return [candidate.resolve()] if candidate.suffix.lower() in VIDEO_EXTENSIONS else []
85
+ try:
86
+ return sorted(
87
+ (path.resolve() for path in candidate.iterdir() if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS),
88
+ key=lambda path: path.stat().st_mtime,
89
+ reverse=True,
90
+ )
91
+ except OSError as exc:
92
+ raise PythonEditorError(f"Não foi possível ler a pasta de vídeos: {exc}") from exc
93
+
94
+
95
+ def list_generated_videos(tasks: list[dict[str, Any]]) -> list[Path]:
96
+ paths: dict[str, Path] = {}
97
+ for task in tasks:
98
+ artifacts = task.get("artifacts") or {}
99
+ value = artifacts.get("video")
100
+ if isinstance(value, str) and value.strip():
101
+ candidate = Path(value).expanduser()
102
+ if candidate.is_file() and candidate.suffix.lower() in VIDEO_EXTENSIONS:
103
+ paths[str(candidate.resolve())] = candidate.resolve()
104
+ return sorted(paths.values(), key=lambda path: path.stat().st_mtime, reverse=True)
105
+
106
+
107
+ def list_scripts() -> list[Path]:
108
+ return sorted((path for path in _scripts().glob("*.py") if path.is_file()), key=lambda path: path.stat().st_mtime, reverse=True)
109
+
110
+
111
+ def read_script(path: Path) -> str:
112
+ candidate = path.resolve()
113
+ if _scripts().resolve() not in candidate.parents:
114
+ raise PythonEditorError("O script seleccionado tem de estar na pasta local de scripts do Thunderbolt.")
115
+ try:
116
+ return candidate.read_text(encoding="utf-8")
117
+ except OSError as exc:
118
+ raise PythonEditorError(f"Não foi possível ler o script: {exc}") from exc
119
+
120
+
121
+ def save_script(name: str, content: str) -> Path:
122
+ safe_name = _safe_filename(name, "script")
123
+ if not safe_name.lower().endswith(".py"):
124
+ safe_name += ".py"
125
+ if not content.strip():
126
+ raise PythonEditorError("O script não pode ficar vazio.")
127
+ target = (_scripts() / safe_name).resolve()
128
+ if _scripts().resolve() not in target.parents:
129
+ raise PythonEditorError("O nome do script não é válido.")
130
+ target.write_text(content, encoding="utf-8")
131
+ return target
132
+
133
+
134
+ def store_uploaded_asset(name: str, content: bytes, *, audio: bool = False) -> Path:
135
+ if not content:
136
+ raise PythonEditorError("O ficheiro enviado está vazio.")
137
+ allowed = AUDIO_EXTENSIONS if audio else VIDEO_EXTENSIONS
138
+ suffix = Path(name).suffix.lower()
139
+ if suffix not in allowed:
140
+ raise PythonEditorError("O ficheiro enviado não tem uma extensão suportada.")
141
+ directory = _root() / ("audio_inputs" if audio else "video_inputs")
142
+ directory.mkdir(parents=True, exist_ok=True)
143
+ digest = hashlib.sha256(content).hexdigest()[:16]
144
+ target = directory / f"{digest}-{_safe_filename(name, 'media')}"
145
+ if not target.exists():
146
+ target.write_bytes(content)
147
+ return target
148
+
149
+
150
+ def _run(command: list[str], output: Path, operation: str, source: Path, *, extra: dict[str, Any] | None = None) -> tuple[Path, dict[str, Any]]:
151
+ output.parent.mkdir(parents=True, exist_ok=True)
152
+ try:
153
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
154
+ except OSError as exc:
155
+ raise PythonEditorError(f"Não foi possível iniciar FFmpeg: {exc}") from exc
156
+ if completed.returncode != 0 or not output.is_file() or output.stat().st_size == 0:
157
+ if output.exists():
158
+ output.unlink()
159
+ detail = (completed.stderr or completed.stdout or "erro desconhecido").strip()
160
+ raise PythonEditorError(f"A operação {operation} falhou: {detail[-1200:]}")
161
+ run_info = {
162
+ "operation": operation,
163
+ "source": str(source),
164
+ "output": str(output),
165
+ "command": command,
166
+ "created_at": _now(),
167
+ }
168
+ if extra:
169
+ run_info.update(extra)
170
+ return output, run_info
171
+
172
+
173
+ def _ffmpeg(ffmpeg_path: str | None = None) -> str:
174
+ try:
175
+ return _resolve_ffmpeg(ffmpeg_path)
176
+ except RuntimeError as exc:
177
+ raise PythonEditorError(str(exc)) from exc
178
+
179
+
180
+ def _atempo_chain(speed: float) -> str:
181
+ factors: list[float] = []
182
+ remaining = float(speed)
183
+ while remaining > 2.0:
184
+ factors.append(2.0)
185
+ remaining /= 2.0
186
+ while remaining < 0.5:
187
+ factors.append(0.5)
188
+ remaining /= 0.5
189
+ factors.append(remaining)
190
+ return ",".join(f"atempo={factor:.6f}" for factor in factors)
191
+
192
+
193
+ def trim_video(source: Path, start_seconds: float, end_seconds: float, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
194
+ source = _validate_video(source)
195
+ start = max(0.0, float(start_seconds))
196
+ end = float(end_seconds)
197
+ if end <= start:
198
+ raise PythonEditorError("O fim do corte deve ser superior ao início.")
199
+ output = _output_path(source, "corte")
200
+ command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-ss", f"{start:.3f}", "-to", f"{end:.3f}", "-i", str(source), "-map", "0", "-c", "copy", "-avoid_negative_ts", "make_zero", str(output)]
201
+ return _run(command, output, "corte", source, extra={"start_seconds": start, "end_seconds": end})
202
+
203
+
204
+ def remove_audio(source: Path, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
205
+ source = _validate_video(source)
206
+ output = _output_path(source, "sem-audio")
207
+ command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-map", "0:v:0", "-c:v", "copy", "-an", str(output)]
208
+ return _run(command, output, "remoção de áudio", source)
209
+
210
+
211
+ def extract_audio(source: Path, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
212
+ source = _validate_video(source)
213
+ output = _output_path(source, "audio", ".mp3")
214
+ command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-vn", "-codec:a", "libmp3lame", "-q:a", "2", str(output)]
215
+ return _run(command, output, "extracção de áudio", source)
216
+
217
+
218
+ def resize_video(source: Path, width: int, height: int, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
219
+ source = _validate_video(source)
220
+ width = int(width)
221
+ height = int(height)
222
+ if width < 16 or height < 16:
223
+ raise PythonEditorError("As dimensões do vídeo devem ser pelo menos 16x16.")
224
+ output = _output_path(source, "redimensionado")
225
+ scale = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
226
+ command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-vf", scale, "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-movflags", "+faststart", str(output)]
227
+ return _run(command, output, "redimensionamento", source, extra={"width": width, "height": height})
228
+
229
+
230
+ def change_speed(source: Path, speed: float, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
231
+ source = _validate_video(source)
232
+ speed = float(speed)
233
+ if speed < 0.25 or speed > 4.0:
234
+ raise PythonEditorError("A velocidade deve estar entre 0,25x e 4x.")
235
+ output = _output_path(source, "velocidade")
236
+ command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-filter:v", f"setpts=PTS/{speed:.6f}", "-filter:a", _atempo_chain(speed), "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-movflags", "+faststart", str(output)]
237
+ return _run(command, output, "alteração de velocidade", source, extra={"speed": speed})
238
+
239
+
240
+ def replace_audio(source: Path, audio: Path, *, ffmpeg_path: str | None = None) -> tuple[Path, dict[str, Any]]:
241
+ source = _validate_video(source)
242
+ audio = _validate_audio(audio)
243
+ output = _output_path(source, "audio-substituido")
244
+ command = [_ffmpeg(ffmpeg_path), "-y", "-hide_banner", "-loglevel", "error", "-i", str(source), "-i", str(audio), "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-shortest", str(output)]
245
+ return _run(command, output, "substituição de áudio", source, extra={"audio": str(audio)})
246
+
247
+
248
+ def save_edit_record(source: Path, output: Path, run_info: dict[str, Any]) -> dict[str, Any]:
249
+ record = {
250
+ "id": hashlib.sha256(f"{source}:{output}:{run_info.get('created_at', '')}".encode("utf-8")).hexdigest()[:16],
251
+ "source_name": source.name,
252
+ "source_path": str(source),
253
+ "output_name": output.name,
254
+ "output_path": str(output),
255
+ "operation": run_info.get("operation", "editor"),
256
+ "parameters": {key: value for key, value in run_info.items() if key not in {"command", "source", "output", "created_at", "operation"}},
257
+ "created_at": run_info.get("created_at", _now()),
258
+ }
259
+ storage.append_json("python_editor_edits.json", record)
260
+ return record
261
+
262
+
263
+ def editor_manifest(record: dict[str, Any]) -> bytes:
264
+ return (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
265
+
266
+
267
+ def list_edit_records() -> list[dict[str, Any]]:
268
+ records = storage.read_json("python_editor_edits.json", [])
269
+ return list(reversed(records)) if isinstance(records, list) else []
@@ -23,6 +23,7 @@ DEFAULTS: dict[str, Any] = {
23
23
  "uploads.json": [],
24
24
  "niche_apify_runs.json": [],
25
25
  "metadata_edits.json": [],
26
+ "python_editor_edits.json": [],
26
27
  "mcp_server.json": {
27
28
  "enabled": False,
28
29
  "host": "127.0.0.1",
@@ -82,6 +83,8 @@ DEFAULTS: dict[str, Any] = {
82
83
  "youtube_api_key": "",
83
84
  "youtube_client_id": "",
84
85
  "youtube_client_secret": "",
86
+ "youtube_batch_accounts": [],
87
+ "youtube_batch_selected_account_id": "",
85
88
  "kaggle_username": "",
86
89
  "kaggle_api_key": "",
87
90
  "kaggle_kernel_slug": "thunderbolt-niche-finder",
@@ -227,7 +230,7 @@ def seed_blueprints() -> None:
227
230
 
228
231
 
229
232
  def ensure_storage() -> None:
230
- for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", NICHES_DATA]:
233
+ for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
231
234
  path.mkdir(parents=True, exist_ok=True)
232
235
  seed_blueprints()
233
236
  for filename, default in DEFAULTS.items():
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import secrets
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from integrations.platforms import IntegrationResult
12
+
13
+ BATCH_SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
14
+
15
+
16
+ def _text(value: Any) -> str:
17
+ return str(value or "").strip()
18
+
19
+
20
+ def account_key(account: dict[str, Any]) -> str:
21
+ raw = _text(account.get("id") or account.get("email"))
22
+ digest = hashlib.sha256(raw.lower().encode("utf-8")).hexdigest()[:16]
23
+ slug = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-")[:36] or "google-account"
24
+ return f"{slug}-{digest}"
25
+
26
+
27
+ def token_path(storage_root: Path, account: dict[str, Any]) -> Path:
28
+ return Path(storage_root) / "state" / "youtube_batch_tokens" / f"{account_key(account)}.json"
29
+
30
+
31
+ def _client_config(account: dict[str, Any]) -> dict[str, Any]:
32
+ return {
33
+ "installed": {
34
+ "client_id": _text(account.get("client_id")),
35
+ "client_secret": _text(account.get("client_secret")),
36
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
37
+ "token_uri": "https://oauth2.googleapis.com/token",
38
+ "redirect_uris": ["http://localhost"],
39
+ }
40
+ }
41
+
42
+
43
+ def _save_credentials(path: Path, credentials: Any) -> None:
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ temporary = path.with_name(f".{path.name}.{secrets.token_hex(6)}.tmp")
46
+ temporary.write_text(credentials.to_json(), encoding="utf-8")
47
+ try:
48
+ os.chmod(temporary, 0o600)
49
+ except OSError:
50
+ pass
51
+ temporary.replace(path)
52
+ try:
53
+ os.chmod(path, 0o600)
54
+ except OSError:
55
+ pass
56
+
57
+
58
+ def _load_credentials(path: Path) -> Any | None:
59
+ if not path.exists() or path.stat().st_size == 0:
60
+ return None
61
+ try:
62
+ from google.auth.transport.requests import Request
63
+ from google.oauth2.credentials import Credentials
64
+ except ImportError as exc:
65
+ raise RuntimeError("As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.") from exc
66
+ raw = json.loads(path.read_text(encoding="utf-8"))
67
+ credentials = Credentials.from_authorized_user_info(raw, BATCH_SCOPES)
68
+ if credentials.expired and credentials.refresh_token:
69
+ credentials.refresh(Request())
70
+ _save_credentials(path, credentials)
71
+ return credentials
72
+
73
+
74
+ def authorize_account(account: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
75
+ email = _text(account.get("email"))
76
+ if not email or "@" not in email:
77
+ return IntegrationResult(False, "Informe um e-mail Google válido para esta conta.", {"status": "email_not_configured"})
78
+ if not _text(account.get("client_id")) or not _text(account.get("client_secret")):
79
+ return IntegrationResult(False, "Preencha o OAuth Client ID e o OAuth Client Secret desta conta.", {"status": "oauth_not_configured", "email": email})
80
+ try:
81
+ from google_auth_oauthlib.flow import InstalledAppFlow
82
+ except ImportError as exc:
83
+ return IntegrationResult(False, "As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.", {"status": "missing_dependencies", "error": str(exc)})
84
+ try:
85
+ flow = InstalledAppFlow.from_client_config(_client_config(account), BATCH_SCOPES)
86
+ credentials = flow.run_local_server(
87
+ host="localhost",
88
+ port=0,
89
+ open_browser=open_browser,
90
+ access_type="offline",
91
+ prompt="consent",
92
+ include_granted_scopes="true",
93
+ )
94
+ path = token_path(storage_root, account)
95
+ _save_credentials(path, credentials)
96
+ return IntegrationResult(True, f"Conta Google autorizada para listagem de canais: {email}.", {"status": "authorized", "email": email, "token_path": str(path)})
97
+ except Exception as exc:
98
+ return IntegrationResult(False, f"A autorização da conta {email} falhou: {exc}", {"status": "authorization_failed", "email": email})
99
+
100
+
101
+ def delete_account_token(account: dict[str, Any], storage_root: Path) -> None:
102
+ path = token_path(storage_root, account)
103
+ try:
104
+ path.unlink(missing_ok=True)
105
+ except OSError:
106
+ pass
107
+
108
+
109
+ def account_status(account: dict[str, Any], storage_root: Path) -> IntegrationResult:
110
+ email = _text(account.get("email"))
111
+ if not email or not _text(account.get("client_id")) or not _text(account.get("client_secret")):
112
+ return IntegrationResult(False, "Conta incompleta: informe e-mail, Client ID e Client Secret.", {"status": "not_configured", "email": email})
113
+ path = token_path(storage_root, account)
114
+ if not path.exists():
115
+ return IntegrationResult(False, "Conta configurada, mas ainda não autorizada.", {"status": "requires_authorization", "email": email})
116
+ try:
117
+ raw = json.loads(path.read_text(encoding="utf-8"))
118
+ if isinstance(raw, dict) and (raw.get("refresh_token") or raw.get("access_token")):
119
+ return IntegrationResult(True, f"Conta autorizada: {email}", {"status": "ready", "email": email, "token_path": str(path), "refresh_on_use": bool(raw.get("refresh_token"))})
120
+ except (OSError, ValueError) as exc:
121
+ return IntegrationResult(False, f"Token da conta {email} inválido: {exc}", {"status": "invalid_token", "email": email})
122
+ return IntegrationResult(False, f"Token da conta {email} não está pronto.", {"status": "requires_authorization", "email": email})
123
+
124
+
125
+ def _channel_record(item: dict[str, Any]) -> dict[str, Any]:
126
+ channel_id = _text(item.get("id"))
127
+ snippet = item.get("snippet") or {}
128
+ statistics = item.get("statistics") or {}
129
+ custom_url = _text(snippet.get("customUrl"))
130
+ url = f"https://www.youtube.com/{custom_url.lstrip('/')}" if custom_url else f"https://www.youtube.com/channel/{channel_id}"
131
+ thumbnails = snippet.get("thumbnails") or {}
132
+ thumbnail = (thumbnails.get("high") or thumbnails.get("medium") or thumbnails.get("default") or {}).get("url", "")
133
+
134
+ def integer(value: Any) -> int | None:
135
+ try:
136
+ return int(value) if value is not None else None
137
+ except (TypeError, ValueError):
138
+ return None
139
+
140
+ return {
141
+ "youtube_channel_id": channel_id,
142
+ "name": _text(snippet.get("title")) or channel_id,
143
+ "url": url,
144
+ "handle": custom_url,
145
+ "description": _text(snippet.get("description")),
146
+ "thumbnail_url": _text(thumbnail),
147
+ "subscriber_count": integer(statistics.get("subscriberCount")),
148
+ "video_count": integer(statistics.get("videoCount")),
149
+ "view_count": integer(statistics.get("viewCount")),
150
+ "metrics_source": "youtube_data_api_oauth_mine",
151
+ "published_at": _text(snippet.get("publishedAt")),
152
+ }
153
+
154
+
155
+ def list_my_channels(account: dict[str, Any], storage_root: Path) -> IntegrationResult:
156
+ status = account_status(account, storage_root)
157
+ if not status.ok:
158
+ return status
159
+ try:
160
+ credentials = _load_credentials(token_path(storage_root, account))
161
+ if credentials is None:
162
+ return IntegrationResult(False, "Autorize primeiro esta conta Google.", {"status": "requires_authorization"})
163
+ from googleapiclient.discovery import build
164
+
165
+ youtube = build("youtube", "v3", credentials=credentials, cache_discovery=False)
166
+ channels: list[dict[str, Any]] = []
167
+ page_token = None
168
+ while True:
169
+ kwargs: dict[str, Any] = {
170
+ "part": "snippet,contentDetails,statistics",
171
+ "mine": True,
172
+ "maxResults": 50,
173
+ }
174
+ if page_token:
175
+ kwargs["pageToken"] = page_token
176
+ response = youtube.channels().list(**kwargs).execute()
177
+ channels.extend(_channel_record(item) for item in response.get("items", []))
178
+ page_token = response.get("nextPageToken")
179
+ if not page_token:
180
+ break
181
+ return IntegrationResult(True, f"{len(channels)} canal(is) encontrado(s) na conta {account.get('email')}.", {"status": "listed", "email": account.get("email", ""), "channels": channels, "count": len(channels)})
182
+ except ImportError as exc:
183
+ return IntegrationResult(False, "A biblioteca Google API Client ainda não está instalada. Execute a instalação do Thunderbolt novamente.", {"status": "missing_dependencies", "error": str(exc)})
184
+ except Exception as exc:
185
+ return IntegrationResult(False, f"Não foi possível listar os canais da conta {account.get('email', '')}: {exc}", {"status": "list_failed", "email": account.get("email", ""), "error": str(exc)})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.39",
3
+ "version": "0.2.41",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",