@danhachuel/thunderbolt 0.2.76 → 0.2.78

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.75
5
+ > **Versão deste manual:** 0.2.78
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)
@@ -23,7 +23,7 @@ A instalação assistida cria um ambiente local separado para evitar misturar as
23
23
  | FFmpeg | Disponibilizado pelo pacote Python `imageio-ffmpeg` |
24
24
  | Porta da aplicação | `3030`, configurável com `THUNDERBOLT_PORT` |
25
25
 
26
- A aplicação não instala drivers de GPU, Docker, modelos Whisper ou credenciais externas. As chaves de API do MoneyPrinterTurbo são configuradas na aba **Configurações** e sincronizadas com o `config.toml` do clone local.
26
+ A aplicação não instala drivers de GPU, Docker, modelos Whisper ou credenciais externas. As chaves de API do MoneyPrinterTurbo são configuradas na aba **Configurações** e sincronizadas com o `config.toml` do clone local. O idioma da interface e o idioma dos vídeos são preferências independentes.
27
27
 
28
28
  O MoneyPrinterTurbo declara Python 3.11 ou superior como requisito e documenta a instalação com `uv` ou com `venv + pip`. A aplicação segue o mesmo princípio e adiciona um instalador assistido próprio.[1]
29
29
 
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.76 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.77 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.
@@ -190,6 +190,12 @@ $env:THUNDERBOLT_VENV="C:\ContentHermes\.venv"
190
190
  npx --yes @danhachuel/thunderbolt install
191
191
  ```
192
192
 
193
+ ## Idiomas e selector de bandeira
194
+
195
+ O Thunderbolt inclui as dez opções de idioma usadas pelo fluxo MoneyPrinterTurbo: **🇺🇸 Inglês (en)**, **🇨🇳 Chinês Simplificado (zh)**, **🇩🇪 Alemão (de)**, **🇻🇳 Vietnamita (vi)**, **🇹🇷 Turco (tr)**, **🇧🇷 Português (pt)**, **🇷🇺 Russo (ru)**, **🇪🇸 Espanhol (es)**, **🇮🇩 Indonésio (id)** e **🇮🇹 Italiano (it)**. A criação de vídeos mantém ainda os rótulos históricos para não invalidar Blueprints e tarefas antigas.
196
+
197
+ No canto superior direito, imediatamente à esquerda de **Deploy**, o botão com a bandeira actual abre o selector do **Idioma da interface**. A escolha fica guardada como `ui_language` em `storage/state/settings.json` e, quando existe um caminho MoneyPrinterTurbo configurado, é sincronizada com `[ui].language` no `config.toml`. Na Criação de Vídeos e em Roteiros, o selector **Script Language** mostra bandeira, nome e código, mas guarda o código curto; essa escolha é sincronizada com `[ui].video_language`.
198
+
193
199
  ## 4. Diagnóstico antes de iniciar
194
200
 
195
201
  Depois da instalação, execute:
@@ -436,7 +442,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
436
442
 
437
443
  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.
438
444
 
439
- 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.76 install`; o instalador detecta e reutiliza o que já estiver válido.
445
+ 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.77 install`; o instalador detecta e reutiliza o que já estiver válido.
440
446
 
441
447
  ### Niche Finder Apify
442
448
 
@@ -478,16 +484,24 @@ A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista
478
484
 
479
485
  A área **Teste de vozes**, dentro de **Configurações > Configuração API**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
480
486
 
481
- ## Upload, Postiz e Upload directo
487
+ ## Upload, Postiz, Upload-Post e Upload directo
482
488
 
483
489
  A subaba **Postiz**, dentro de **Upload**, permite carregar as integrações Postiz, seleccionar o canal ligado e enviar vídeos MP4 através da Public API. Configure **Activar Postiz como fallback final**, **Postiz API key**, **Postiz Public API Base URL**, **Postiz MCP URL** e, opcionalmente, o ID da integração padrão em **Configuração API > API Keys > Serviços e modelos**. A API key é enviada como valor bruto do cabeçalho `Authorization`; o upload usa `POST /upload` e a publicação usa `POST /posts`.
484
490
 
485
- O botão de envio YouTube segue a ordem fixa **1. API Oficial, 2. Upload directo, 3. Postiz**. A API Oficial regista no storage local até cinco envios bem-sucedidos por dia por conta Gmail. Quando a quota é atingida ou o método falha, o Thunderbolt valida o `credentials.json` e tenta o Upload directo. Postiz só é tentado como último recurso e apenas quando está activo, com API key e integração válida.
491
+ O botão de envio YouTube segue a ordem fixa **1. API Oficial, 2. Upload directo, 3. Postiz**. O **Upload-Post** é uma quarta subaba independente e não altera essa rota automática do YouTube. A API Oficial regista no storage local até cinco envios bem-sucedidos por dia por conta Gmail. Quando a quota é atingida ou o método falha, o Thunderbolt valida o `credentials.json` e tenta o Upload directo. Postiz só é tentado como último recurso e apenas quando está activo, com API key e integração válida.
486
492
 
487
493
  Em **Configurações > Contas Google**, cada conta aparece como um expander identificado por nome e e-mail. Dentro dele existe o uploader **Subir documento de cookies/credenciais** e o input **sessionInfo token desta conta Google**. O documento JSON único contém cookies, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal; o sessionInfo preenchido na conta é sincronizado no mesmo ficheiro, guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
488
494
 
489
495
  Em **Canais Youtube > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
490
496
 
497
+ ### Upload-Post
498
+
499
+ A subaba **Upload-Post** usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para publicar vídeos prontos em uma ou mais plataformas ligadas ao perfil configurado. Em **Configuração API > API Keys > Serviços e modelos > Publicação através do Upload-Post**, active a integração, guarde a API key, o username/perfil e as plataformas padrão. Depois abra **Upload > Upload-Post**, confirme ou altere as plataformas, escreva o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
500
+
501
+ A credencial não é duplicada na página de Upload. A integração envia o ficheiro local como `video` em `multipart/form-data`, usa `platform[]` repetido para cada destino e autentica com `Authorization: Apikey ...`. **Processar em segundo plano** envia `async_upload=true`; quando a API devolver `request_id`, este fica visível e é guardado em `uploads.json`. O histórico local também alimenta a notificação **Upload-Post concluído**. O Upload-Post é distinto do Postiz: não usa os endpoints nem a selecção de integrações do Postiz.
502
+
503
+ ### Upload directo
504
+
491
505
  A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, criado automaticamente ao adicionar a conta, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O uploader aceita um documento JSON completo ou parcial; o merge actualiza somente os valores presentes e preserva os restantes. O documento é guardado fora de `storage/state/`, com permissões locais restritas.
492
506
 
493
507
  Associe cada canal à conta Google correcta em **Canais Youtube**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
package/README.md CHANGED
@@ -15,7 +15,7 @@ A primeira versão implementa a camada UI independente com:
15
15
  | Blueprints Youtube | 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
17
  | Canais Youtube | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente; cartões com botão Editar, nicho/referências visível, Prompts do Canal/Blueprint, Narrador/voz e gestão dos últimos 10 vídeos |
18
- | 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 |
18
+ | Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; idiomas históricos preservados e dez códigos MoneyPrinterTurbo com bandeiras; 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 | Menu expansível com a subaba **Automação Youtube**, onde ficam os 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
  | AI Influencers | Menu expansível com Personagens, Redes Sociais e Tutorial Meta |
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 |
@@ -25,6 +25,14 @@ A primeira versão implementa a camada UI independente com:
25
25
  | Configurações | **Contas Google**, **Configuração API** e **Notificações**; API Keys divididas entre **Serviços e modelos** e **Fontes de materiais**, com várias chaves independentes por fonte, além de contas Google/YouTube por cartão, `INNERTUBE_API_KEY` no documento da conta, providers, TTS, Nano Banana, Postiz, TikTok e Upload-Post |
26
26
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
27
27
 
28
+ ## Upload-Post — publicação para múltiplas plataformas
29
+
30
+ A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
31
+
32
+ A API key, o username/perfil e a lista inicial de plataformas continuam em **Configuração API > API Keys > Serviços e modelos**, no expander **Publicação através do Upload-Post**. A subaba não pede novamente a credencial. O cliente envia `multipart/form-data` para `https://api.upload-post.com/api/upload`, repete `platform[]` para cada destino e guarda a resposta, o `request_id` e o resultado no histórico local de uploads. A opção **Processar em segundo plano** usa `async_upload=true` quando a API estiver configurada para processamento assíncrono.
33
+
34
+ O Upload-Post é independente do Postiz: Postiz continua a usar o seu fluxo próprio de asset + post, enquanto Upload-Post publica directamente nas plataformas ligadas ao username configurado. Uma publicação aceite pelo Upload-Post também é reconciliada no centro de **Notificações**.
35
+
28
36
  ## Upload directo — credenciais por conta e por canal
29
37
 
30
38
  O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações > Contas Google > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
@@ -38,7 +46,13 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
38
46
 
39
47
  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.
40
48
 
41
- ## Navegação da UI 0.2.76
49
+ ## Idiomas e selector rápido da UI
50
+
51
+ O Thunderbolt mantém os idiomas históricos da criação de vídeos e acrescenta as dez opções canónicas do MoneyPrinterTurbo: **🇺🇸 Inglês (en)**, **🇨🇳 Chinês Simplificado (zh)**, **🇩🇪 Alemão (de)**, **🇻🇳 Vietnamita (vi)**, **🇹🇷 Turco (tr)**, **🇧🇷 Português (pt)**, **🇷🇺 Russo (ru)**, **🇪🇸 Espanhol (es)**, **🇮🇩 Indonésio (id)** e **🇮🇹 Italiano (it)**. Os códigos curtos são os valores persistidos e sincronizados com `ui.video_language` no `config.toml`; a bandeira e o nome são apenas a apresentação visual.
52
+
53
+ No canto superior direito da aplicação existe um botão compacto com a bandeira do idioma actual, colocado imediatamente à esquerda do botão **Deploy** do Streamlit. Ao abrir o botão, seleccione outro idioma; a preferência é guardada em `storage/state/settings.json` como `ui_language`, e a navegação lateral actualiza-se sem alterar o idioma seleccionado para os vídeos. A primeira implementação traduz o chrome principal da navegação e mantém os rótulos específicos das páginas compatíveis com o fluxo existente.
54
+
55
+ ## Navegação da UI 0.2.78
42
56
 
43
57
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais** e **Tutorial Meta**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
44
58
 
@@ -169,9 +183,9 @@ thunderbolt
169
183
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
170
184
 
171
185
  ```powershell
172
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76 install
173
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76 doctor
174
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76
186
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77 install
187
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77 doctor
188
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77
175
189
  ```
176
190
 
177
191
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -195,7 +209,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
195
209
 
196
210
  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.
197
211
 
198
- **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ção API > API Keys > Serviços e modelos**. 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.76 install`; o instalador detecta e reutiliza componentes já válidos.
212
+ **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ção API > API Keys > Serviços e modelos**. 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.77 install`; o instalador detecta e reutiliza componentes já válidos.
199
213
 
200
214
  ## Editor Python baseado no PYEdit
201
215
 
package/app/main.py CHANGED
@@ -37,6 +37,7 @@ from hermes_ui.material_sources import material_api_keys, material_source_catalo
37
37
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
38
38
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
39
39
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
40
+ from hermes_ui.languages import LANGUAGE_CODES, language_code, language_flag, language_label, ui_text, video_language_label, video_language_options
40
41
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
41
42
  from hermes_ui.script_generation import generate_script_document
42
43
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
@@ -45,6 +46,7 @@ from hermes_ui.creative_generation import CreativeGenerationError, generate_crea
45
46
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
46
47
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
47
48
  from integrations.postiz import PostizAdapter
49
+ from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
48
50
  from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
49
51
  from integrations.youtube_direct_upload import YouTubeDirectUploader
50
52
  from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
@@ -125,8 +127,10 @@ VIDEO_LANGUAGE_OPTIONS = [
125
127
  "50 – Hausa",
126
128
  ]
127
129
 
128
- # Mantemos estes valores separados para que Criação de Vídeos e Roteiros partilhem
129
- # exactamente os mesmos selectores sem alterar a lista histórica de idiomas.
130
+ # Mantemos esta lista histórica para preservar Blueprints, testes e tarefas antigas.
131
+ # A UI acrescenta os dez códigos canónicos do MoneyPrinterTurbo com bandeiras.
132
+ VIDEO_LANGUAGE_SELECTION_OPTIONS = ["music", *LANGUAGE_CODES, *[item for item in VIDEO_LANGUAGE_OPTIONS if item not in {"01 – Inglês", "06 – Alemão", "15 – Italiano", "29 – Turco", "31 – Russo", "36 – Português (Brasil)", "39 – Mandarim", "41 – Espanhol (LatAm)", "42 – Vietnamita", "44 – Indonésio"}]]
133
+
130
134
  VIDEO_FORMAT_OPTIONS = ["wide", "shorts", "music"]
131
135
  VIDEO_CONCATENATION_OPTIONS = ["Random Concatenation (Recommended)", "Sequential Concatenation"]
132
136
  VIDEO_TRANSITION_OPTIONS = ["None", "Fade", "Dissolve"]
@@ -184,6 +188,12 @@ st.markdown("""
184
188
  [data-testid="stRadio"] label { border:1px solid #2a4052; border-radius:12px; padding:.65rem .8rem; background:#101b25; min-height:4.3rem; }
185
189
  [data-testid="stRadio"] label:has(input:checked) { border-color:#c59b55; background:linear-gradient(145deg, rgba(96,71,33,.42), rgba(17,27,37,.95)); }
186
190
  [data-testid="stStatusWidget"] { border-color:#2a4052 !important; background:#101b25 !important; }
191
+ /* Selector compacto de idioma, visualmente colocado à esquerda do Deploy do Streamlit. */
192
+ [data-testid="stPopover"] { position:fixed !important; top:0.34rem !important; right:4.75rem !important; z-index:100000 !important; }
193
+ [data-testid="stPopover"] > button { min-width:2.25rem !important; width:2.25rem !important; height:2.25rem !important; padding:0 !important; border:1px solid #2a4052 !important; border-radius:8px !important; background:#101b25 !important; }
194
+ [data-testid="stPopover"] > button p { font-size:1.12rem !important; line-height:1 !important; margin:0 !important; }
195
+ [data-testid="stPopover"] [data-testid="stPopoverBody"] { min-width:18rem; }
196
+ @media (max-width: 700px) { [data-testid="stPopover"] { right:3.2rem !important; } }
187
197
 
188
198
  /* Cores dos chips por identidade da plataforma, sem depender da ordem de selecção. */
189
199
  [data-testid="stMultiSelectTagsContainer"] span[data-tag] { color:#ffffff !important; border:0 !important; font-weight:700 !important; }
@@ -205,6 +215,58 @@ st.markdown("""
205
215
  """, unsafe_allow_html=True)
206
216
 
207
217
 
218
+ def current_ui_language() -> str:
219
+ settings = read_json("settings.json", {})
220
+ stored = st.session_state.get("ui_language") or settings.get("ui_language") or "pt"
221
+ normalized = language_code(stored)
222
+ st.session_state["ui_language"] = normalized
223
+ return normalized
224
+
225
+
226
+ def save_ui_language(value: str) -> str:
227
+ normalized = language_code(value)
228
+ settings = read_json("settings.json", {})
229
+ settings["ui_language"] = normalized
230
+ write_json("settings.json", settings)
231
+ try:
232
+ sync_moneyprinter_config(settings, str(settings.get("moneyprinter_path") or ""))
233
+ except Exception:
234
+ # The selector remains usable even when no MoneyPrinterTurbo path exists.
235
+ pass
236
+ st.session_state["ui_language"] = normalized
237
+ return normalized
238
+
239
+
240
+ def save_video_language(value: str) -> str:
241
+ normalized = "music" if str(value or "").strip().casefold() == "music" else language_code(value)
242
+ settings = read_json("settings.json", {})
243
+ settings["video_language"] = normalized
244
+ write_json("settings.json", settings)
245
+ try:
246
+ sync_moneyprinter_config(settings, str(settings.get("moneyprinter_path") or ""))
247
+ except Exception:
248
+ pass
249
+ st.session_state["video_language"] = normalized
250
+ return normalized
251
+
252
+
253
+ def render_ui_language_picker(language: str) -> None:
254
+ """Render the flag-only popover over the Streamlit toolbar area."""
255
+ current = language_code(language)
256
+ with st.popover(language_flag(current), help="Trocar idioma da interface"):
257
+ st.markdown(f"**{ui_text('Idioma da interface', current)}**")
258
+ selected = st.selectbox(
259
+ ui_text("Idioma da interface", current),
260
+ list(LANGUAGE_CODES),
261
+ index=list(LANGUAGE_CODES).index(current),
262
+ format_func=language_label,
263
+ key="ui_language_picker",
264
+ )
265
+ if selected != current:
266
+ save_ui_language(selected)
267
+ st.rerun()
268
+
269
+
208
270
  def card(label: str, value: str | int, note: str = ""):
209
271
  st.markdown(f'<div class="content-card"><div class="content-label">{label}</div><div class="content-value">{value}</div><div class="small-muted">{note}</div></div>', unsafe_allow_html=True)
210
272
 
@@ -376,10 +438,12 @@ def render_video_generation_settings(prefix: str, *, current_language: str = "")
376
438
  key=f"{prefix}_video_subject",
377
439
  placeholder="Ex.: How AI is changing everyday life",
378
440
  )
441
+ normalized_current_language = "music" if str(current_language or "").strip().casefold() in {"music", "00 – apenas música de fundo (sem falas)", "00 - apenas música de fundo (sem falas)"} else language_code(current_language)
379
442
  settings["script_language"] = st.selectbox(
380
443
  "Script Language",
381
- VIDEO_LANGUAGE_OPTIONS,
382
- index=VIDEO_LANGUAGE_OPTIONS.index(current_language) if current_language in VIDEO_LANGUAGE_OPTIONS else 0,
444
+ VIDEO_LANGUAGE_SELECTION_OPTIONS,
445
+ index=VIDEO_LANGUAGE_SELECTION_OPTIONS.index(normalized_current_language) if normalized_current_language in VIDEO_LANGUAGE_SELECTION_OPTIONS else 0,
446
+ format_func=video_language_label,
383
447
  key=f"{prefix}_script_language",
384
448
  )
385
449
  with subject_cols[1]:
@@ -602,7 +666,7 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
602
666
  edited_name = st.text_input("Nome do canal", value=str(channel.get("name") or ""))
603
667
  edited_url = st.text_input("URL", value=str(channel.get("url") or ""))
604
668
  edited_handle = st.text_input("Handle", value=str(channel.get("handle") or ""))
605
- edited_language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=["Português", "English", "Español", "Français", "Deutsch"].index(channel.get("language")) if channel.get("language") in {"Português", "English", "Español", "Français", "Deutsch"} else 0)
669
+ edited_language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index(language_code(channel.get("language") or "pt")), format_func=language_label)
606
670
  style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
607
671
  style_value = {"pexels": "Pexels/Pixabay", "music": "Apenas Música"}.get(str(channel.get("style_wide") or "pexels"), str(channel.get("style_wide") or "Pexels/Pixabay"))
608
672
  edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
@@ -1051,7 +1115,7 @@ def render_channels():
1051
1115
  name = st.text_input("Nome do canal", value=imported.get("name", ""), key="yt_import_name")
1052
1116
  url = st.text_input("URL", value=imported.get("url", source if source.startswith("http") else ""), key="yt_import_url")
1053
1117
  handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
1054
- language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="yt_import_language")
1118
+ language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index(language_code(imported.get("language") or "pt")), format_func=language_label, key="yt_import_language")
1055
1119
  style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="yt_import_style")
1056
1120
  blueprint = st.selectbox("Blueprint padrão do canal", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
1057
1121
  voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
@@ -1154,7 +1218,7 @@ def render_channels():
1154
1218
  with defaults_cols[1]:
1155
1219
  batch_voice = st.selectbox("Voz padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="batch_channel_voice")
1156
1220
  with defaults_cols[2]:
1157
- batch_language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], key="batch_channel_language")
1221
+ batch_language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="batch_channel_language")
1158
1222
  with defaults_cols[3]:
1159
1223
  batch_style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], key="batch_channel_style")
1160
1224
  import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", use_container_width=True)
@@ -1200,7 +1264,7 @@ def render_channels():
1200
1264
  handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
1201
1265
  description = st.text_area("Descrição", key="manual_channel_description")
1202
1266
  niche = st.text_input("Canais de Referência / Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
1203
- language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
1267
+ language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="manual_channel_language")
1204
1268
  style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
1205
1269
  manual_blueprint_items = blueprint_catalog()
1206
1270
  manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
@@ -1360,10 +1424,11 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1360
1424
  )
1361
1425
  mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
1362
1426
  selected_one: dict[str, Any] | None = None
1363
- legacy_language = st.session_state.get("video_language")
1364
- legacy_language_map = {"Português": "36Português (Brasil)", "English": "01 Inglês", "Español": "41 Espanhol (LatAm)"}
1365
- if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
1366
- st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
1427
+ legacy_language = st.session_state.get("video_language") or read_json("settings.json", {}).get("video_language")
1428
+ if str(legacy_language or "").strip().casefold() in {"music", "00apenas música de fundo (sem falas)", "00 - apenas música de fundo (sem falas)"}:
1429
+ st.session_state["video_language"] = "music"
1430
+ else:
1431
+ st.session_state["video_language"] = language_code(legacy_language or "pt")
1367
1432
  generation_settings: dict[str, Any] = {}
1368
1433
  if mode == "general":
1369
1434
  selected = [str(channel["id"]) for channel in all_channels if channel.get("id")]
@@ -1624,6 +1689,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1624
1689
  fmt = generation_settings["video_format"]
1625
1690
  submitted = st.form_submit_button("Criar tarefas", type="primary")
1626
1691
  if submitted:
1692
+ save_video_language(language)
1627
1693
  style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
1628
1694
  if style == "music" and not music_path:
1629
1695
  st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
@@ -1752,7 +1818,7 @@ def render_scripts():
1752
1818
  legacy_script_language = str(st.session_state.get("script_language") or "")
1753
1819
  script_settings = render_video_generation_settings(
1754
1820
  "pipeline_scripts",
1755
- current_language=legacy_script_language if legacy_script_language in VIDEO_LANGUAGE_OPTIONS else "",
1821
+ current_language=legacy_script_language or str(read_json("settings.json", {}).get("video_language") or "pt"),
1756
1822
  )
1757
1823
  language = script_settings["script_language"]
1758
1824
  structure_notes = script_settings["script_structure_notes"]
@@ -2722,11 +2788,13 @@ def render_upload_direct():
2722
2788
 
2723
2789
  def render_upload():
2724
2790
  st.title("Upload")
2725
- upload_tab, direct_tab, postiz_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz"])
2791
+ upload_tab, direct_tab, postiz_tab, upload_post_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz", "Upload-Post"])
2726
2792
  with direct_tab:
2727
2793
  render_upload_direct()
2728
2794
  with postiz_tab:
2729
2795
  render_upload_postiz()
2796
+ with upload_post_tab:
2797
+ render_upload_post()
2730
2798
  with upload_tab:
2731
2799
  render_upload_conventional()
2732
2800
 
@@ -2808,6 +2876,78 @@ def render_upload_postiz():
2808
2876
  (st.success if result.ok else st.error)(result.message)
2809
2877
 
2810
2878
 
2879
+ def render_upload_post():
2880
+ st.subheader("Upload-Post")
2881
+ st.caption("Envie um vídeo para uma ou mais plataformas ligadas ao seu perfil Upload-Post. A API key é lida da Configuração API e nunca é mostrada nesta aba.")
2882
+ settings = read_json("settings.json", {})
2883
+ uploader = UploadPostAdapter(settings)
2884
+ status = uploader.status()
2885
+ if not status.ok:
2886
+ st.warning(status.message)
2887
+ if not uploader.enabled:
2888
+ st.info("Active Upload-Post em Configuração API > API Keys > Serviços e modelos e guarde a configuração.")
2889
+ elif not uploader.api_key:
2890
+ st.info("Introduza a API key do Upload-Post em Configuração API > API Keys > Serviços e modelos.")
2891
+ return
2892
+
2893
+ configured_platforms = normalize_upload_post_platforms(uploader.platforms)
2894
+ selected_platforms = st.multiselect(
2895
+ "Plataformas Upload-Post",
2896
+ list(UPLOAD_POST_PLATFORM_OPTIONS),
2897
+ default=configured_platforms,
2898
+ format_func=lambda value: "Facebook Pages" if value == "facebook" else "X (Twitter)" if value == "x" else value.title(),
2899
+ key="upload_post_platforms_selector",
2900
+ help="Seleccione uma ou mais plataformas já ligadas ao perfil Upload-Post.",
2901
+ )
2902
+ async_upload = st.checkbox(
2903
+ "Processar em segundo plano",
2904
+ value=False,
2905
+ key="upload_post_async_upload",
2906
+ help="Envia async_upload=true para a API e mostra o request ID devolvido, quando existir.",
2907
+ )
2908
+ if not selected_platforms:
2909
+ st.info("Seleccione pelo menos uma plataforma antes de publicar.")
2910
+
2911
+ tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
2912
+ if not tasks:
2913
+ st.info("Não há vídeos prontos para enviar pelo Upload-Post.")
2914
+ return
2915
+ for task in tasks:
2916
+ artifacts = task.get("artifacts", {}) or {}
2917
+ video_path = artifacts.get("video", "")
2918
+ with st.container(border=True):
2919
+ st.write(f"**{task.get('topic', 'Vídeo Thunderbolt')}** — {uploader.username}")
2920
+ st.caption(video_path or "Sem caminho de vídeo registado")
2921
+ title = st.text_input("Título Upload-Post", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"upload_post_title_{task['id']}")
2922
+ description = st.text_area("Descrição Upload-Post", value=task.get("description", ""), key=f"upload_post_description_{task['id']}", height=90)
2923
+ if st.button("Enviar vídeo pelo Upload-Post", type="primary", key=f"upload_post_send_{task['id']}", disabled=not selected_platforms):
2924
+ result = uploader.upload_video(
2925
+ video_path,
2926
+ title=title,
2927
+ description=description,
2928
+ user=uploader.username,
2929
+ platforms=selected_platforms,
2930
+ async_upload=async_upload,
2931
+ )
2932
+ record = {
2933
+ "id": uuid.uuid4().hex,
2934
+ "task_id": task.get("id"),
2935
+ "destination": "Upload-Post",
2936
+ "target": {"username": uploader.username, "platforms": selected_platforms},
2937
+ "status": "published" if result.ok else "failed",
2938
+ "message": result.message,
2939
+ "data": result.data,
2940
+ "created_at": now(),
2941
+ }
2942
+ uploads = read_json("uploads.json", [])
2943
+ uploads.append(record)
2944
+ write_json("uploads.json", uploads)
2945
+ reconcile_persisted_notifications()
2946
+ (st.success if result.ok else st.error)(result.message)
2947
+ if result.ok and result.data.get("request_id"):
2948
+ st.caption(f"Request ID Upload-Post: {result.data['request_id']}")
2949
+
2950
+
2811
2951
  UPLOAD_DESTINATION_TARGET_KEYS = {
2812
2952
  "TikTok": "tiktok_accounts",
2813
2953
  "Instagram": "instagram_profiles",
@@ -3956,13 +4096,16 @@ def main():
3956
4096
  if current_page not in {item[0] for item in top_pages + pipeline_items + pipeline_tiktok_items + automation_items + edition_items + models_ai_items + niche_finder_items + settings_items}:
3957
4097
  current_page = "Início"
3958
4098
  st.session_state["page"] = current_page
4099
+ ui_language = current_ui_language()
4100
+ render_ui_language_picker(ui_language)
3959
4101
 
3960
4102
  def navigate(target: str):
3961
4103
  st.session_state["page"] = target
3962
4104
  st.rerun()
3963
4105
 
3964
4106
  def render_nav_button(target: str, icon: str, label: str, *, child: bool = False):
3965
- if st.button(label, key=f"nav_{target}", icon=icon, use_container_width=True, type="primary" if current_page == target else "secondary"):
4107
+ display_label = ui_text(label, ui_language)
4108
+ if st.button(display_label, key=f"nav_{target}", icon=icon, use_container_width=True, type="primary" if current_page == target else "secondary"):
3966
4109
  navigate(target)
3967
4110
 
3968
4111
  with st.sidebar:
@@ -3970,31 +4113,31 @@ def main():
3970
4113
  st.markdown(f'<div class="tb-brand"><span class="tb-brand-name">Thunderbolt</span>{version_markup}</div>', unsafe_allow_html=True)
3971
4114
  for target, icon, label in top_pages:
3972
4115
  if target == "Pipeline":
3973
- with st.expander("Pipeline", expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
4116
+ with st.expander(ui_text("Pipeline", ui_language), expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
3974
4117
  for child_target, child_icon, child_label in pipeline_items:
3975
4118
  render_nav_button(child_target, child_icon, child_label, child=True)
3976
4119
  elif target == "Pipeline TikTok":
3977
- with st.expander("Pipeline TikTok", expanded=current_page in {item[0] for item in pipeline_tiktok_items}, icon=":material/video_library:"):
4120
+ with st.expander(ui_text("Pipeline TikTok", ui_language), expanded=current_page in {item[0] for item in pipeline_tiktok_items}, icon=":material/video_library:"):
3978
4121
  for child_target, child_icon, child_label in pipeline_tiktok_items:
3979
4122
  render_nav_button(child_target, child_icon, child_label, child=True)
3980
4123
  elif target == "Automação":
3981
- with st.expander("Automação", expanded=current_page in {item[0] for item in automation_items}, icon=":material/schedule:"):
4124
+ with st.expander(ui_text("Automação", ui_language), expanded=current_page in {item[0] for item in automation_items}, icon=":material/schedule:"):
3982
4125
  for child_target, child_icon, child_label in automation_items:
3983
4126
  render_nav_button(child_target, child_icon, child_label, child=True)
3984
4127
  elif target == "Edição":
3985
- with st.expander("Edição", expanded=current_page in {item[0] for item in edition_items}, icon=":material/edit:"):
4128
+ with st.expander(ui_text("Edição", ui_language), expanded=current_page in {item[0] for item in edition_items}, icon=":material/edit:"):
3986
4129
  for child_target, child_icon, child_label in edition_items:
3987
4130
  render_nav_button(child_target, child_icon, child_label, child=True)
3988
4131
  elif target == "AI Influencers":
3989
- with st.expander("AI Influencers", expanded=current_page in {item[0] for item in models_ai_items}, icon=":material/smart_toy:"):
4132
+ with st.expander(ui_text("AI Influencers", ui_language), expanded=current_page in {item[0] for item in models_ai_items}, icon=":material/smart_toy:"):
3990
4133
  for child_target, child_icon, child_label in models_ai_items:
3991
4134
  render_nav_button(child_target, child_icon, child_label, child=True)
3992
4135
  elif target == "Niche Finder":
3993
- with st.expander("Niche Finder", expanded=current_page in {item[0] for item in niche_finder_items}, icon=":material/search:"):
4136
+ with st.expander(ui_text("Niche Finder", ui_language), expanded=current_page in {item[0] for item in niche_finder_items}, icon=":material/search:"):
3994
4137
  for child_target, child_icon, child_label in niche_finder_items:
3995
4138
  render_nav_button(child_target, child_icon, child_label, child=True)
3996
4139
  elif target == "Configurações":
3997
- with st.expander("Configurações", expanded=current_page in {item[0] for item in settings_items}, icon=":material/settings:"):
4140
+ with st.expander(ui_text("Configurações", ui_language), expanded=current_page in {item[0] for item in settings_items}, icon=":material/settings:"):
3998
4141
  for child_target, child_icon, child_label in settings_items:
3999
4142
  render_nav_button(child_target, child_icon, child_label, child=True)
4000
4143
  else:
@@ -37,7 +37,7 @@ def _json_response(response: requests.Response, action: str) -> Any:
37
37
  def _headers(token: str) -> dict[str, str]:
38
38
  token = str(token or "").strip()
39
39
  if not token:
40
- raise ApifyError("Configure o Apify API Token em Configurações Técnicas antes de iniciar.")
40
+ raise ApifyError("Configure o Apify API Token em Configuração API > API Keys > Serviços e modelos antes de iniciar.")
41
41
  return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
42
42
 
43
43
 
@@ -38,15 +38,15 @@ def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
38
38
  base_url = base_url or "http://127.0.0.1:11434/v1"
39
39
  if not base_url:
40
40
  raise CreativeGenerationError(
41
- f"O provider LLM '{provider}' não tem Base URL configurada em Configurações Técnicas > API Keys."
41
+ f"O provider LLM '{provider}' não tem Base URL configurada em Configuração API > API Keys > Serviços e modelos."
42
42
  )
43
43
  if not model:
44
44
  raise CreativeGenerationError(
45
- f"O provider LLM '{provider}' não tem modelo configurado em Configurações Técnicas > API Keys."
45
+ f"O provider LLM '{provider}' não tem modelo configurado em Configuração API > API Keys > Serviços e modelos."
46
46
  )
47
47
  if provider not in {"ollama", "litellm"} and not key:
48
48
  raise CreativeGenerationError(
49
- f"Configure a API key do provider LLM '{provider}' em Configurações Técnicas > API Keys."
49
+ f"Configure a API key do provider LLM '{provider}' em Configuração API > API Keys > Serviços e modelos."
50
50
  )
51
51
  return provider, key, base_url.rstrip("/"), model
52
52
 
@@ -0,0 +1,197 @@
1
+ """Canonical UI and video language catalog shared by Thunderbolt screens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ # The persisted value is always the short MoneyPrinterTurbo-compatible code.
9
+ LANGUAGE_CATALOG: tuple[dict[str, str], ...] = (
10
+ {"code": "en", "name": "Inglês", "flag": "🇺🇸", "locale": "en-US"},
11
+ {"code": "zh", "name": "Chinês Simplificado", "flag": "🇨🇳", "locale": "zh-CN"},
12
+ {"code": "de", "name": "Alemão", "flag": "🇩🇪", "locale": "de-DE"},
13
+ {"code": "vi", "name": "Vietnamita", "flag": "🇻🇳", "locale": "vi-VN"},
14
+ {"code": "tr", "name": "Turco", "flag": "🇹🇷", "locale": "tr-TR"},
15
+ {"code": "pt", "name": "Português", "flag": "🇧🇷", "locale": "pt-BR"},
16
+ {"code": "ru", "name": "Russo", "flag": "🇷🇺", "locale": "ru-RU"},
17
+ {"code": "es", "name": "Espanhol", "flag": "🇪🇸", "locale": "es-ES"},
18
+ {"code": "id", "name": "Indonésio", "flag": "🇮🇩", "locale": "id-ID"},
19
+ {"code": "it", "name": "Italiano", "flag": "🇮🇹", "locale": "it-IT"},
20
+ )
21
+
22
+ LANGUAGE_BY_CODE = {item["code"]: item for item in LANGUAGE_CATALOG}
23
+ LANGUAGE_CODES = tuple(item["code"] for item in LANGUAGE_CATALOG)
24
+
25
+ # Legacy labels used by existing channels, scripts and tests. They remain
26
+ # readable while new UI selections persist only the canonical short code.
27
+ LEGACY_LANGUAGE_CODES = {
28
+ "01 – inglês": "en",
29
+ "01 - inglês": "en",
30
+ "06 – alemão": "de",
31
+ "06 - alemão": "de",
32
+ "15 – italiano": "it",
33
+ "15 - italiano": "it",
34
+ "29 – turco": "tr",
35
+ "29 - turco": "tr",
36
+ "31 – russo": "ru",
37
+ "31 - russo": "ru",
38
+ "36 – português (brasil)": "pt",
39
+ "36 - português (brasil)": "pt",
40
+ "39 – mandarim": "zh",
41
+ "39 - mandarim": "zh",
42
+ "13 – espanhol (espanha)": "es",
43
+ "41 - espanhol (latam)": "es",
44
+ "42 – vietnamita": "vi",
45
+ "42 - vietnamita": "vi",
46
+ "44 – indonésio": "id",
47
+ "44 - indonésio": "id",
48
+ "13 – espanhol (espanha)": "es",
49
+ "13 - espanhol (espanha)": "es",
50
+ "37 – cantonês": "zh",
51
+ "37 - cantonês": "zh",
52
+ "english": "en",
53
+ "inglês": "en",
54
+ "inglés": "en",
55
+ "zh": "zh",
56
+ "chinês": "zh",
57
+ "chinês simplificado": "zh",
58
+ "deutsch": "de",
59
+ "alemão": "de",
60
+ "vietnamita": "vi",
61
+ "turco": "tr",
62
+ "português": "pt",
63
+ "português (brasil)": "pt",
64
+ "portuguese": "pt",
65
+ "ru": "ru",
66
+ "russo": "ru",
67
+ "español": "es",
68
+ "espanhol": "es",
69
+ "indonésio": "id",
70
+ "bahasa indonesia": "id",
71
+ "italiano": "it",
72
+ }
73
+
74
+
75
+ def language_code(value: Any, default: str = "pt") -> str:
76
+ """Normalize a canonical code or a legacy display label to a code."""
77
+ raw = str(value or "").strip()
78
+ if raw in LANGUAGE_BY_CODE:
79
+ return raw
80
+ lowered = raw.casefold()
81
+ if lowered in LEGACY_LANGUAGE_CODES:
82
+ return LEGACY_LANGUAGE_CODES[lowered]
83
+ for item in LANGUAGE_CATALOG:
84
+ if lowered in {item["name"].casefold(), item["locale"].casefold()}:
85
+ return item["code"]
86
+ return default if default in LANGUAGE_BY_CODE else "pt"
87
+
88
+
89
+ def language_label(value: Any, *, include_code: bool = True) -> str:
90
+ """Return the visual label with the requested flag and optional code."""
91
+ code = language_code(value)
92
+ item = LANGUAGE_BY_CODE[code]
93
+ suffix = f" ({item['code']})" if include_code else ""
94
+ return f"{item['flag']} {item['name']}{suffix}"
95
+
96
+
97
+ def language_option_labels(*, include_code: bool = True) -> list[str]:
98
+ return [language_label(item["code"], include_code=include_code) for item in LANGUAGE_CATALOG]
99
+
100
+
101
+ def language_option_codes() -> list[str]:
102
+ return list(LANGUAGE_CODES)
103
+
104
+
105
+ UI_TRANSLATIONS: dict[str, dict[str, str]] = {
106
+ "en": {
107
+ "Início": "Home", "Niche Finder": "Niche Finder", "Pipeline": "Pipeline", "Pipeline TikTok": "TikTok Pipeline",
108
+ "Automação": "Automation", "Edição": "Editing", "AI Influencers": "AI Influencers", "Configurações": "Settings",
109
+ "Criação de Vídeos": "Video Creation", "Criação de Músicas": "Music Creation", "Roteiros": "Scripts", "Upload": "Upload",
110
+ "Prompts Master": "Prompt Masters", "Contas TikTok": "TikTok Accounts", "Automação Youtube": "YouTube Automation",
111
+ "Canais Youtube": "YouTube Channels", "Blueprints Youtube": "YouTube Blueprints", "Contas Google": "Google Accounts",
112
+ "Configuração API": "API Configuration", "Notificações": "Notifications", "Niche Finder Kaggle": "Niche Finder Kaggle",
113
+ "Niche Finder Apify": "Niche Finder Apify", "Limpador de Metadados": "Metadata Cleaner", "Cortes": "Cuts",
114
+ "Editor Python": "Python Editor", "Download Mídia": "Media Download", "Personagens": "Characters", "Redes Sociais": "Social Networks",
115
+ "Tutorial Meta": "Meta Tutorial", "Idioma da interface": "Interface language",
116
+ },
117
+ "zh": {
118
+ "Início": "首页", "Pipeline": "视频流程", "Pipeline TikTok": "TikTok 流程", "Automação": "自动化", "Edição": "编辑", "Configurações": "设置",
119
+ "Criação de Vídeos": "视频创作", "Criação de Músicas": "音乐创作", "Roteiros": "脚本", "Upload": "上传", "Contas Google": "Google 账户", "Notificações": "通知", "Idioma da interface": "界面语言",
120
+ },
121
+ "de": {
122
+ "Início": "Startseite", "Pipeline": "Pipeline", "Pipeline TikTok": "TikTok-Pipeline", "Automação": "Automatisierung", "Edição": "Bearbeitung", "Configurações": "Einstellungen",
123
+ "Criação de Vídeos": "Videoerstellung", "Criação de Músicas": "Musikerstellung", "Roteiros": "Skripte", "Upload": "Upload", "Contas Google": "Google-Konten", "Notificações": "Benachrichtigungen", "Idioma da interface": "Oberflächensprache",
124
+ },
125
+ "vi": {
126
+ "Início": "Trang chủ", "Pipeline": "Quy trình", "Pipeline TikTok": "Quy trình TikTok", "Automação": "Tự động hóa", "Edição": "Chỉnh sửa", "Configurações": "Cài đặt",
127
+ "Criação de Vídeos": "Tạo video", "Criação de Músicas": "Tạo nhạc", "Roteiros": "Kịch bản", "Upload": "Tải lên", "Contas Google": "Tài khoản Google", "Notificações": "Thông báo", "Idioma da interface": "Ngôn ngữ giao diện",
128
+ },
129
+ "tr": {
130
+ "Início": "Ana sayfa", "Pipeline": "Akış", "Pipeline TikTok": "TikTok akışı", "Automação": "Otomasyon", "Edição": "Düzenleme", "Configurações": "Ayarlar",
131
+ "Criação de Vídeos": "Video oluşturma", "Criação de Músicas": "Müzik oluşturma", "Roteiros": "Senaryolar", "Upload": "Yükleme", "Contas Google": "Google hesapları", "Notificações": "Bildirimler", "Idioma da interface": "Arayüz dili",
132
+ },
133
+ "ru": {
134
+ "Início": "Главная", "Pipeline": "Конвейер", "Pipeline TikTok": "Конвейер TikTok", "Automação": "Автоматизация", "Edição": "Редактирование", "Configurações": "Настройки",
135
+ "Criação de Vídeos": "Создание видео", "Criação de Músicas": "Создание музыки", "Roteiros": "Сценарии", "Upload": "Загрузка", "Contas Google": "Аккаунты Google", "Notificações": "Уведомления", "Idioma da interface": "Язык интерфейса",
136
+ },
137
+ "es": {
138
+ "Início": "Inicio", "Pipeline": "Flujo", "Pipeline TikTok": "Flujo de TikTok", "Automação": "Automatización", "Edição": "Edición", "Configurações": "Configuración",
139
+ "Criação de Vídeos": "Creación de vídeos", "Criação de Músicas": "Creación de música", "Roteiros": "Guiones", "Upload": "Carga", "Contas Google": "Cuentas de Google", "Notificações": "Notificaciones", "Idioma da interface": "Idioma de la interfaz",
140
+ },
141
+ "id": {
142
+ "Início": "Beranda", "Pipeline": "Alur", "Pipeline TikTok": "Alur TikTok", "Automação": "Otomatisasi", "Edição": "Penyuntingan", "Configurações": "Pengaturan",
143
+ "Criação de Vídeos": "Pembuatan video", "Criação de Músicas": "Pembuatan musik", "Roteiros": "Skrip", "Upload": "Unggah", "Contas Google": "Akun Google", "Notificações": "Notifikasi", "Idioma da interface": "Bahasa antarmuka",
144
+ },
145
+ "it": {
146
+ "Início": "Home", "Pipeline": "Pipeline", "Pipeline TikTok": "Pipeline TikTok", "Automação": "Automazione", "Edição": "Modifica", "Configurações": "Impostazioni",
147
+ "Criação de Vídeos": "Creazione video", "Criação de Músicas": "Creazione musicale", "Roteiros": "Script", "Upload": "Caricamento", "Contas Google": "Account Google", "Notificações": "Notifiche", "Idioma da interface": "Lingua dell'interfaccia",
148
+ },
149
+ "pt": {},
150
+ }
151
+
152
+
153
+ def ui_text(value: str, language: Any = "pt") -> str:
154
+ code = language_code(language)
155
+ return UI_TRANSLATIONS.get(code, {}).get(value, value)
156
+
157
+
158
+ def language_flag(value: Any) -> str:
159
+ return LANGUAGE_BY_CODE[language_code(value)]["flag"]
160
+
161
+
162
+ def language_locale(value: Any) -> str:
163
+ return LANGUAGE_BY_CODE[language_code(value)]["locale"]
164
+
165
+
166
+ def video_language_label(value: Any) -> str:
167
+ """Format a video-language value while preserving all historical options."""
168
+ raw = str(value or "").strip()
169
+ if raw.casefold() in {
170
+ "music",
171
+ "00 – apenas música de fundo (sem falas)",
172
+ "00 - apenas música de fundo (sem falas)",
173
+ }:
174
+ return "🎵 Apenas Música de Fundo (Sem Falas)"
175
+ normalized = language_code(raw, default="")
176
+ if normalized in LANGUAGE_BY_CODE and (raw.casefold() in LEGACY_LANGUAGE_CODES or raw in LANGUAGE_BY_CODE):
177
+ return language_label(normalized)
178
+ return raw
179
+
180
+
181
+ def video_language_options() -> list[str]:
182
+ return ["music", *LANGUAGE_CODES]
183
+
184
+
185
+ __all__ = [
186
+ "LANGUAGE_CATALOG",
187
+ "LANGUAGE_BY_CODE",
188
+ "LANGUAGE_CODES",
189
+ "language_code",
190
+ "language_flag",
191
+ "language_label",
192
+ "language_locale",
193
+ "language_option_codes",
194
+ "language_option_labels",
195
+ "video_language_label",
196
+ "video_language_options",
197
+ ]
@@ -36,6 +36,7 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
36
36
  {"code": "upload_instagram_success", "category": "Upload", "label": "Upload Instagram concluído", "description": "Quando um vídeo for publicado num perfil Instagram."},
37
37
  {"code": "upload_facebook_pages_success", "category": "Upload", "label": "Upload Facebook Pages concluído", "description": "Quando um vídeo for publicado numa Facebook Page."},
38
38
  {"code": "upload_postiz_success", "category": "Upload", "label": "Upload Postiz concluído", "description": "Quando um vídeo for enviado e publicado através do Postiz."},
39
+ {"code": "upload_upload_post_success", "category": "Upload", "label": "Upload-Post concluído", "description": "Quando um vídeo for aceite pelo Upload-Post para publicação nas plataformas seleccionadas."},
39
40
  {"code": "mcp_operation_completed", "category": "Integrações", "label": "Operação MCP concluída", "description": "Quando uma operação mutável de integração MCP terminar com sucesso."},
40
41
  )
41
42
  EVENTS_BY_CODE = {item["code"]: item for item in EVENT_CATALOG}
@@ -249,6 +250,8 @@ def _upload_notifications() -> int:
249
250
  "facebook pages": "upload_facebook_pages_success",
250
251
  "facebook_pages": "upload_facebook_pages_success",
251
252
  "postiz": "upload_postiz_success",
253
+ "upload-post": "upload_upload_post_success",
254
+ "upload_post": "upload_upload_post_success",
252
255
  "youtube direct frontend": "upload_youtube_success",
253
256
  }
254
257
  for upload in uploads:
@@ -115,6 +115,7 @@ DEFAULTS: dict[str, Any] = {
115
115
  "upload_instagram_success": True,
116
116
  "upload_facebook_pages_success": True,
117
117
  "upload_postiz_success": True,
118
+ "upload_upload_post_success": True,
118
119
  "mcp_operation_completed": True,
119
120
  },
120
121
  "kaggle_username": "",
@@ -192,6 +193,8 @@ DEFAULTS: dict[str, Any] = {
192
193
  "log_level": "DEBUG",
193
194
  "listen_host": "127.0.0.1",
194
195
  "listen_port": 8080,
196
+ "ui_language": "pt",
197
+ "video_language": "pt",
195
198
  "video_source": "pexels",
196
199
  "match_materials_to_script": False,
197
200
  "endpoint": "",
@@ -87,7 +87,7 @@ def generate_thumbnail_image(
87
87
  ) -> Path:
88
88
  api_key = str(settings.get("gemini_image_api_key") or "").strip()
89
89
  if not api_key:
90
- raise ThumbnailGenerationError("Configure a API key Nano Banana em Configurações Técnicas > API Keys.")
90
+ raise ThumbnailGenerationError("Configure a API key Nano Banana em Configuração API > API Keys > Serviços e modelos.")
91
91
  clean_prompt = str(prompt or "").strip()
92
92
  if not clean_prompt:
93
93
  raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
+ from hermes_ui.languages import language_code
6
7
  from hermes_ui.material_sources import all_material_api_keys, selected_material_source
7
8
 
8
9
  try:
@@ -32,6 +33,7 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
32
33
  elevenlabs = dict(config.get("elevenlabs") or {})
33
34
  chatterbox = dict(config.get("chatterbox") or {})
34
35
  proxy = dict(config.get("proxy") or {})
36
+ ui = dict(config.get("ui") or {})
35
37
 
36
38
  app_map = {
37
39
  "llm_provider": "llm_provider",
@@ -123,6 +125,11 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
123
125
  app["video_source"] = selected_material_source(settings)
124
126
  config["app"] = app
125
127
 
128
+ ui["language"] = language_code(settings.get("ui_language", ui.get("language", "pt")))
129
+ requested_video_language = str(settings.get("video_language") or ui.get("video_language") or "").strip()
130
+ ui["video_language"] = "" if requested_video_language.casefold() == "music" else language_code(requested_video_language)
131
+ config["ui"] = ui
132
+
126
133
  whisper["model_size"] = settings.get("whisper_model_size", whisper.get("model_size", "large-v3"))
127
134
  whisper["device"] = settings.get("whisper_device", whisper.get("device", "cpu"))
128
135
  whisper["compute_type"] = settings.get("whisper_compute_type", whisper.get("compute_type", "int8"))
@@ -33,7 +33,7 @@ class PostizAdapter:
33
33
 
34
34
  def status(self) -> IntegrationResult:
35
35
  if not self.api_key:
36
- return self._error("Postiz não configurado: adicione a API key em Configurações Técnicas.")
36
+ return self._error("Postiz não configurado: adicione a API key em Configuração API > API Keys > Serviços e modelos.")
37
37
  return IntegrationResult(True, f"Postiz configurado em {self.base_url}.", {"base_url": self.base_url, "mcp_url": self.mcp_url})
38
38
 
39
39
  def list_integrations(self) -> IntegrationResult:
@@ -0,0 +1,152 @@
1
+ """Upload-Post API adapter for publishing local video artefacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from .platforms import IntegrationResult
12
+
13
+
14
+ DEFAULT_UPLOAD_POST_BASE_URL = "https://api.upload-post.com/api"
15
+ UPLOAD_POST_PLATFORM_OPTIONS = (
16
+ "tiktok",
17
+ "instagram",
18
+ "youtube",
19
+ "facebook",
20
+ "linkedin",
21
+ "x",
22
+ "threads",
23
+ "pinterest",
24
+ "reddit",
25
+ "bluesky",
26
+ "discord",
27
+ "telegram",
28
+ )
29
+ _PLATFORM_ALIASES = {
30
+ "facebook pages": "facebook",
31
+ "facebook_pages": "facebook",
32
+ "twitter": "x",
33
+ "x (twitter)": "x",
34
+ }
35
+
36
+
37
+ def normalize_upload_post_platforms(value: Any) -> list[str]:
38
+ """Return stable Upload-Post platform slugs without duplicates."""
39
+ values = value if isinstance(value, (list, tuple, set)) else str(value or "").replace("\n", ",").split(",")
40
+ result: list[str] = []
41
+ seen: set[str] = set()
42
+ supported = set(UPLOAD_POST_PLATFORM_OPTIONS)
43
+ for item in values:
44
+ platform = str(item or "").strip().lower()
45
+ platform = _PLATFORM_ALIASES.get(platform, platform)
46
+ if platform in supported and platform not in seen:
47
+ result.append(platform)
48
+ seen.add(platform)
49
+ return result
50
+
51
+
52
+ class UploadPostAdapter:
53
+ """Small deterministic client for Upload-Post's ``POST /upload`` endpoint."""
54
+
55
+ def __init__(self, settings: dict[str, Any] | None = None):
56
+ self.settings = settings or {}
57
+ self.api_key = str(self.settings.get("upload_post_api_key") or "").strip()
58
+ self.enabled = bool(self.settings.get("upload_post_enabled", False))
59
+ self.username = str(self.settings.get("upload_post_username") or "").strip()
60
+ self.platforms = normalize_upload_post_platforms(self.settings.get("upload_post_platforms", ""))
61
+ self.base_url = str(self.settings.get("upload_post_base_url") or DEFAULT_UPLOAD_POST_BASE_URL).strip().rstrip("/")
62
+
63
+ def _headers(self) -> dict[str, str]:
64
+ return {"Authorization": f"Apikey {self.api_key}"}
65
+
66
+ def _error(self, message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
67
+ return IntegrationResult(False, message, data or {})
68
+
69
+ def status(self) -> IntegrationResult:
70
+ if not self.enabled:
71
+ return self._error("Upload-Post está desactivado em Configuração API.")
72
+ if not self.api_key:
73
+ return self._error("Upload-Post não configurado: adicione a API key em Configuração API > API Keys > Serviços e modelos.")
74
+ if not self.username:
75
+ return self._error("Upload-Post não configurado: adicione o username/perfil em Configuração API.")
76
+ if not self.platforms:
77
+ return self._error("Upload-Post não configurado: indique pelo menos uma plataforma em Configuração API.")
78
+ return IntegrationResult(
79
+ True,
80
+ f"Upload-Post configurado para {self.username} ({', '.join(self.platforms)}).",
81
+ {"base_url": self.base_url, "username": self.username, "platforms": self.platforms},
82
+ )
83
+
84
+ def upload_video(
85
+ self,
86
+ video_path: str | Path,
87
+ *,
88
+ title: str,
89
+ description: str = "",
90
+ user: str | None = None,
91
+ platforms: list[str] | None = None,
92
+ async_upload: bool = False,
93
+ ) -> IntegrationResult:
94
+ """Upload a local video to one or more connected Upload-Post platforms."""
95
+ if not self.enabled:
96
+ return self._error("Upload-Post está desactivado em Configuração API.")
97
+ if not self.api_key:
98
+ return self._error("Upload-Post não configurado: adicione a API key em Configuração API.")
99
+ path = Path(video_path)
100
+ if not path.is_file():
101
+ return self._error(f"Vídeo não encontrado para o Upload-Post: {path}")
102
+ selected_user = str(user or self.username).strip()
103
+ selected_platforms = normalize_upload_post_platforms(platforms or self.platforms)
104
+ if not selected_user:
105
+ return self._error("Indique o username/perfil ligado ao Upload-Post antes de publicar.")
106
+ if not selected_platforms:
107
+ return self._error("Seleccione pelo menos uma plataforma do Upload-Post antes de publicar.")
108
+
109
+ form_data: list[tuple[str, str]] = [
110
+ ("user", selected_user),
111
+ *[("platform[]", platform) for platform in selected_platforms],
112
+ ("title", str(title or "Vídeo Thunderbolt").strip()[:500] or "Vídeo Thunderbolt"),
113
+ ("description", str(description or "").strip()),
114
+ ("async_upload", "true" if async_upload else "false"),
115
+ ]
116
+ content_type = mimetypes.guess_type(path.name)[0] or "video/mp4"
117
+ try:
118
+ with path.open("rb") as handle:
119
+ response = requests.post(
120
+ f"{self.base_url}/upload",
121
+ headers=self._headers(),
122
+ data=form_data,
123
+ files={"video": (path.name, handle, content_type)},
124
+ timeout=240,
125
+ )
126
+ except requests.RequestException as exc:
127
+ return self._error(f"Não foi possível contactar o Upload-Post: {exc}", {"platforms": selected_platforms, "user": selected_user})
128
+ if response.status_code >= 400:
129
+ return self._error(
130
+ f"Upload-Post rejeitou o vídeo (HTTP {response.status_code}): {response.text[:500]}",
131
+ {"platforms": selected_platforms, "user": selected_user},
132
+ )
133
+ try:
134
+ payload: Any = response.json()
135
+ except ValueError:
136
+ return self._error("Upload-Post devolveu uma resposta que não é JSON.", {"response": response.text[:2000]})
137
+ payload_dict = payload if isinstance(payload, dict) else {"response": payload}
138
+ request_id = str(payload_dict.get("request_id") or payload_dict.get("requestId") or "").strip()
139
+ message = "Upload-Post aceitou o vídeo para publicação."
140
+ if request_id:
141
+ message += f" Request ID: {request_id}."
142
+ return IntegrationResult(
143
+ True,
144
+ message,
145
+ {
146
+ "payload": payload_dict,
147
+ "request_id": request_id,
148
+ "user": selected_user,
149
+ "platforms": selected_platforms,
150
+ "async_upload": bool(async_upload),
151
+ },
152
+ )
@@ -154,7 +154,7 @@ def upload_with_default_route(
154
154
 
155
155
  postiz = PostizAdapter(settings)
156
156
  if not bool(settings.get("postiz_enabled", False)):
157
- postiz_result = IntegrationResult(False, "Postiz está desactivado em Configurações Técnicas.", {})
157
+ postiz_result = IntegrationResult(False, "Postiz está desactivado em Configuração API > API Keys > Serviços e modelos.", {})
158
158
  attempts.append(_attempt_record("Postiz", postiz_result, skipped=True))
159
159
  else:
160
160
  postiz_publisher = postiz_publisher or postiz.publish_video
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.76",
3
+ "version": "0.2.78",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",