@danhachuel/thunderbolt 0.2.55 → 0.2.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +9 -5
- package/README.md +12 -8
- package/app/main.py +120 -6
- package/hermes_ui/storage.py +7 -0
- package/integrations/postiz.py +173 -0
- package/integrations/upload_routing.py +177 -0
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.2.
|
|
5
|
+
> **Versão deste manual:** 0.2.56
|
|
6
6
|
> **Pacote npm:** `@danhachuel/thunderbolt`
|
|
7
7
|
> **Porta padrão da UI:** `localhost:3030`
|
|
8
8
|
> **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
|
|
@@ -100,13 +100,13 @@ Execute:
|
|
|
100
100
|
Windows PowerShell ou MobaXterm:
|
|
101
101
|
|
|
102
102
|
```powershell
|
|
103
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.56 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.2.56 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.
|
|
@@ -428,7 +428,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
428
428
|
|
|
429
429
|
Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
|
|
430
430
|
|
|
431
|
-
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
431
|
+
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.56 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
432
432
|
|
|
433
433
|
### Niche Finder Apify
|
|
434
434
|
|
|
@@ -468,7 +468,11 @@ A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista
|
|
|
468
468
|
|
|
469
469
|
A área **Teste de vozes**, dentro de **Configurações > Configurações Técnicas**, é 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.
|
|
470
470
|
|
|
471
|
-
## Upload directo
|
|
471
|
+
## Upload, Postiz e Upload directo
|
|
472
|
+
|
|
473
|
+
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ções Técnicas > API Keys**. A API key é enviada como valor bruto do cabeçalho `Authorization`; o upload usa `POST /upload` e a publicação usa `POST /posts`.
|
|
474
|
+
|
|
475
|
+
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.
|
|
472
476
|
|
|
473
477
|
Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, 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`.
|
|
474
478
|
|
package/README.md
CHANGED
|
@@ -20,9 +20,9 @@ A primeira versão implementa a camada UI independente com:
|
|
|
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
22
|
| Edição | Menu expansível abaixo de Automação com Limpador de Metadados, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
|
|
23
|
-
| Upload | YouTube via `youtube-automation-agent` adaptado internamente,
|
|
23
|
+
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, fallback ordenado API Oficial → Upload directo → Postiz, upload de MP4 para Postiz via API key/MCP configurável, 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, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, 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, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, Postiz API key/Base URL/MCP URL/integração padrão, 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
|
## Upload directo — credenciais por conta e por canal
|
|
@@ -38,7 +38,7 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
|
|
|
38
38
|
|
|
39
39
|
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
40
|
|
|
41
|
-
## Navegação da UI 0.2.
|
|
41
|
+
## Navegação da UI 0.2.56
|
|
42
42
|
|
|
43
43
|
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**, **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** 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 Youtube**, **Blueprints Youtube**, **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.
|
|
44
44
|
|
|
@@ -161,9 +161,9 @@ thunderbolt
|
|
|
161
161
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
162
162
|
|
|
163
163
|
```powershell
|
|
164
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
165
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
166
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
164
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.56 install
|
|
165
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.56 doctor
|
|
166
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.56
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -187,7 +187,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
|
|
|
187
187
|
|
|
188
188
|
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.
|
|
189
189
|
|
|
190
|
-
**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.
|
|
190
|
+
**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.56 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
191
191
|
|
|
192
192
|
## Editor Python baseado no PYEdit
|
|
193
193
|
|
|
@@ -254,7 +254,7 @@ A aba **Canais Youtube** está dividida em dois fluxos independentes. Em **Impor
|
|
|
254
254
|
|
|
255
255
|
Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo, Blueprint padrão, voz padrão, `DELEGATED_SESSION_ID` e configuração de Automação. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar. A resolução pública aceita URLs `/channel/UC...`, handles e subpáginas; quando existe um ID mas o HTML não traz todos os dados, tenta o feed RSS público. Se o YouTube responder que o canal não existe ou não fornecer metadados, a UI mostra uma mensagem clara e não mantém o formulário de uma pesquisa anterior. Cada cartão de canal tem **Activo** e, logo abaixo, **Apagar canal**, com confirmação; tarefas e artefactos não são apagados.
|
|
256
256
|
|
|
257
|
-
## Upload YouTube
|
|
257
|
+
## Upload YouTube e fallback Postiz
|
|
258
258
|
|
|
259
259
|
O Upload usa como caminho **principal** a lógica do `PublishingSchedulingAgent` do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent), adaptada para Python e executada dentro do processo Streamlit do Thunderbolt. Não é necessário instalar ou iniciar um segundo servidor Node. O fluxo valida o MP4 real, constrói `snippet/status`, faz upload resumível e tenta enviar thumbnail e legendas.
|
|
260
260
|
|
|
@@ -262,6 +262,10 @@ Na aba **Upload**, configure primeiro apenas o **YouTube OAuth Client ID** e o *
|
|
|
262
262
|
|
|
263
263
|
Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separada para o caminho de redundância. Os resultados guardam no histórico local qual mecanismo foi utilizado e as tentativas realizadas, sem guardar segredos.
|
|
264
264
|
|
|
265
|
+
A subaba **Postiz**, dentro de **Upload**, permite carregar as integrações ligadas através de `GET /integrations`, enviar um MP4 para `POST /upload` e criar o post YouTube em `POST /posts`. A API key é enviada como valor bruto do cabeçalho `Authorization`; a base cloud é `https://api.postiz.com/public/v1` e pode ser substituída por uma instalação self-hosted. O modo MCP guarda também a URL Streamable HTTP configurável para uso futuro/alternativo.
|
|
266
|
+
|
|
267
|
+
O botão principal de YouTube usa a ordem **API Oficial → Upload directo → Postiz**. A API Oficial tem um contador local de cinco envios bem-sucedidos por dia por conta Gmail; quando a quota é atingida, ou o método falha, o Thunderbolt tenta o documento de sessão do Upload directo. Só depois de esse caminho falhar tenta o Postiz, desde que esteja activo, tenha API key e tenha um ID de integração configurado.
|
|
268
|
+
|
|
265
269
|
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Ela usa cookies, `sessionInfo`, `INNERTUBE_API_KEY` e `DELEGATED_SESSION_ID` fornecidos manualmente pelo utilizador, cria o vídeo através do endpoint interno e envia o ficheiro em chunks de 256 KiB. Este caminho é experimental, não extrai cookies automaticamente e fica separado do agente YouTube principal.
|
|
266
270
|
|
|
267
271
|
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação Youtube
|
package/app/main.py
CHANGED
|
@@ -39,6 +39,8 @@ from hermes_ui.script_generation import generate_script_document
|
|
|
39
39
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
40
40
|
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
41
41
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
42
|
+
from integrations.postiz import PostizAdapter
|
|
43
|
+
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
42
44
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
43
45
|
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
44
46
|
from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
|
|
@@ -2136,17 +2138,99 @@ def render_upload_direct():
|
|
|
2136
2138
|
|
|
2137
2139
|
def render_upload():
|
|
2138
2140
|
st.title("Upload")
|
|
2139
|
-
upload_tab, direct_tab = st.tabs(["Upload convencional", "Upload directo"])
|
|
2141
|
+
upload_tab, direct_tab, postiz_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz"])
|
|
2140
2142
|
with direct_tab:
|
|
2141
2143
|
render_upload_direct()
|
|
2144
|
+
with postiz_tab:
|
|
2145
|
+
render_upload_postiz()
|
|
2142
2146
|
with upload_tab:
|
|
2143
2147
|
render_upload_conventional()
|
|
2144
2148
|
|
|
2145
2149
|
|
|
2150
|
+
def render_upload_postiz():
|
|
2151
|
+
st.subheader("Upload para Postiz")
|
|
2152
|
+
st.caption("O Thunderbolt envia primeiro o MP4 para o Postiz e cria um post na integração seleccionada. A API key e o servidor são configurados em Configurações Técnicas.")
|
|
2153
|
+
settings = read_json("settings.json", {})
|
|
2154
|
+
postiz = PostizAdapter(settings)
|
|
2155
|
+
if not settings.get("postiz_enabled"):
|
|
2156
|
+
st.warning("Postiz está desactivado. Active-o em Configurações Técnicas > API Keys e guarde a API key antes de enviar.")
|
|
2157
|
+
if not postiz.api_key:
|
|
2158
|
+
st.info("Nenhuma API key Postiz configurada.")
|
|
2159
|
+
return
|
|
2160
|
+
|
|
2161
|
+
integration_catalog = st.session_state.get("postiz_integrations", [])
|
|
2162
|
+
integration_ids = [str(item.get("id")) for item in integration_catalog if isinstance(item, dict) and item.get("id")]
|
|
2163
|
+
integration_labels = {
|
|
2164
|
+
str(item.get("id")): " — ".join(str(value) for value in [item.get("name") or item.get("provider") or item.get("type") or "Integração", item.get("username") or item.get("profile") or item.get("identifier") or ""] if value)
|
|
2165
|
+
for item in integration_catalog if isinstance(item, dict) and item.get("id")
|
|
2166
|
+
}
|
|
2167
|
+
integration_default = str(settings.get("postiz_integration_id", "") or "")
|
|
2168
|
+
if st.button("Carregar integrações Postiz", key="postiz_load_integrations"):
|
|
2169
|
+
result = postiz.list_integrations()
|
|
2170
|
+
if result.ok:
|
|
2171
|
+
st.session_state["postiz_integrations"] = result.data.get("integrations", [])
|
|
2172
|
+
st.success(result.message)
|
|
2173
|
+
st.rerun()
|
|
2174
|
+
st.error(result.message)
|
|
2175
|
+
if integration_ids:
|
|
2176
|
+
selected_integration = st.selectbox(
|
|
2177
|
+
"Canal/integração Postiz",
|
|
2178
|
+
integration_ids,
|
|
2179
|
+
index=integration_ids.index(integration_default) if integration_default in integration_ids else 0,
|
|
2180
|
+
format_func=lambda value: integration_labels.get(value, value),
|
|
2181
|
+
key="postiz_upload_integration",
|
|
2182
|
+
)
|
|
2183
|
+
st.caption("Para alterar a integração padrão, guarde o ID seleccionado em Configurações Técnicas.")
|
|
2184
|
+
else:
|
|
2185
|
+
selected_integration = st.text_input("ID da integração Postiz", value=integration_default, key="postiz_upload_integration_manual", help="Carregue as integrações para seleccionar uma conta ou introduza o ID devolvido pela API do Postiz.").strip()
|
|
2186
|
+
st.info("Carregue as integrações para descobrir os canais Postiz ligados à sua conta.")
|
|
2187
|
+
|
|
2188
|
+
tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
|
|
2189
|
+
if not tasks:
|
|
2190
|
+
st.info("Não há vídeos prontos para enviar para o Postiz.")
|
|
2191
|
+
return
|
|
2192
|
+
for task in tasks:
|
|
2193
|
+
artifacts = task.get("artifacts", {}) or {}
|
|
2194
|
+
video_path = artifacts.get("video", "")
|
|
2195
|
+
thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
|
|
2196
|
+
with st.container(border=True):
|
|
2197
|
+
st.write(f"**{task.get('topic', 'Vídeo Thunderbolt')}** — {task.get('channel_name', 'Canal')}")
|
|
2198
|
+
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
2199
|
+
title = st.text_input("Título Postiz", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"postiz_title_{task['id']}")
|
|
2200
|
+
description = st.text_area("Descrição Postiz", value=task.get("description", ""), key=f"postiz_description_{task['id']}", height=90)
|
|
2201
|
+
visibility = st.selectbox("Visibilidade YouTube no Postiz", ["private", "unlisted", "public"], key=f"postiz_visibility_{task['id']}")
|
|
2202
|
+
if st.button("Enviar vídeo para Postiz", type="primary", key=f"postiz_upload_{task['id']}"):
|
|
2203
|
+
result = postiz.publish_video(
|
|
2204
|
+
video_path,
|
|
2205
|
+
integration_id=selected_integration,
|
|
2206
|
+
title=title,
|
|
2207
|
+
description=description,
|
|
2208
|
+
visibility=visibility,
|
|
2209
|
+
tags=task.get("tags", []) if isinstance(task.get("tags", []), list) else [tag.strip() for tag in str(task.get("tags", "")).split(",") if tag.strip()],
|
|
2210
|
+
thumbnail_path=thumbnail_path,
|
|
2211
|
+
)
|
|
2212
|
+
record = {
|
|
2213
|
+
"task_id": task.get("id"),
|
|
2214
|
+
"destination": "Postiz",
|
|
2215
|
+
"status": "published" if result.ok else "failed",
|
|
2216
|
+
"message": result.message,
|
|
2217
|
+
"data": result.data,
|
|
2218
|
+
"created_at": now(),
|
|
2219
|
+
}
|
|
2220
|
+
uploads = read_json("uploads.json", [])
|
|
2221
|
+
uploads.append(record)
|
|
2222
|
+
write_json("uploads.json", uploads)
|
|
2223
|
+
(st.success if result.ok else st.error)(result.message)
|
|
2224
|
+
|
|
2225
|
+
|
|
2146
2226
|
def render_upload_conventional():
|
|
2147
2227
|
st.title("Upload")
|
|
2148
2228
|
settings = read_json("settings.json", {})
|
|
2149
2229
|
youtube = YouTubeAdapter(settings=settings)
|
|
2230
|
+
channels = read_json("channels.json", [])
|
|
2231
|
+
channel_map = {str(channel.get("id")): channel for channel in channels if channel.get("id")}
|
|
2232
|
+
direct_accounts = {str(account.get("id")): account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")}
|
|
2233
|
+
postiz = PostizAdapter(settings)
|
|
2150
2234
|
tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
|
|
2151
2235
|
destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
|
|
2152
2236
|
|
|
@@ -2156,9 +2240,14 @@ def render_upload_conventional():
|
|
|
2156
2240
|
st.info("Facebook Pages está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
|
|
2157
2241
|
|
|
2158
2242
|
if "YouTube" in destination:
|
|
2159
|
-
st.markdown("**YouTube —
|
|
2160
|
-
st.caption("
|
|
2243
|
+
st.markdown("**YouTube — fluxo recomendado de envio**")
|
|
2244
|
+
st.caption("Ordem automática: 1. API Oficial — até 5 envios bem-sucedidos por dia e por conta Gmail; 2. Upload directo — sessão interna YouTube; 3. Postiz — fallback final configurável.")
|
|
2161
2245
|
status = youtube.upload_status()
|
|
2246
|
+
if settings.get("postiz_enabled"):
|
|
2247
|
+
postiz_status = postiz.status()
|
|
2248
|
+
(st.success if postiz_status.ok else st.warning)(postiz_status.message)
|
|
2249
|
+
else:
|
|
2250
|
+
st.caption("Postiz está desactivado; active-o em Configurações Técnicas para o usar como fallback final.")
|
|
2162
2251
|
status_cols = st.columns(2)
|
|
2163
2252
|
with status_cols[0]:
|
|
2164
2253
|
(st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
|
|
@@ -2188,6 +2277,8 @@ def render_upload_conventional():
|
|
|
2188
2277
|
thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
|
|
2189
2278
|
captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
|
|
2190
2279
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
2280
|
+
channel = channel_map.get(str(task.get("channel_id")), {})
|
|
2281
|
+
account = direct_accounts.get(str(channel.get("google_account_id", "")))
|
|
2191
2282
|
if "YouTube" in destination:
|
|
2192
2283
|
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
2193
2284
|
description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
|
|
@@ -2199,10 +2290,16 @@ def render_upload_conventional():
|
|
|
2199
2290
|
category_id = st.text_input("Category ID", value="22", key=f"yt_category_{task['id']}")
|
|
2200
2291
|
with yt_cols[2]:
|
|
2201
2292
|
language = st.text_input("Idioma", value="pt-BR", key=f"yt_language_{task['id']}")
|
|
2202
|
-
|
|
2293
|
+
quota_count = official_upload_count(channel, account)
|
|
2294
|
+
st.caption(f"API Oficial hoje: {quota_count}/{OFFICIAL_DAILY_LIMIT} envios nesta conta Gmail.")
|
|
2295
|
+
if st.button("Enviar pelo fluxo recomendado", type="primary", key=f"upload_youtube_{task['id']}"):
|
|
2203
2296
|
tags = [tag.strip() for tag in tags_raw.split(",") if tag.strip()]
|
|
2204
|
-
result =
|
|
2205
|
-
|
|
2297
|
+
result = upload_with_default_route(
|
|
2298
|
+
settings,
|
|
2299
|
+
storage_root=STORAGE,
|
|
2300
|
+
channel=channel,
|
|
2301
|
+
account=account,
|
|
2302
|
+
video_path=video_path,
|
|
2206
2303
|
title=title,
|
|
2207
2304
|
description=description,
|
|
2208
2305
|
tags=tags,
|
|
@@ -2579,6 +2676,20 @@ def render_settings():
|
|
|
2579
2676
|
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
|
|
2580
2677
|
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
2581
2678
|
|
|
2679
|
+
with st.expander("Postiz — API key, integração e MCP", expanded=True):
|
|
2680
|
+
st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
|
|
2681
|
+
postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
|
|
2682
|
+
postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
|
|
2683
|
+
postiz_cols = st.columns(2)
|
|
2684
|
+
with postiz_cols[0]:
|
|
2685
|
+
postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
|
|
2686
|
+
postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
|
|
2687
|
+
postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
|
|
2688
|
+
with postiz_cols[1]:
|
|
2689
|
+
postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
|
|
2690
|
+
postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
|
|
2691
|
+
st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
|
|
2692
|
+
|
|
2582
2693
|
if refresh_openai_models:
|
|
2583
2694
|
try:
|
|
2584
2695
|
discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
|
|
@@ -2613,6 +2724,9 @@ def render_settings():
|
|
|
2613
2724
|
"upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
|
|
2614
2725
|
"upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
|
|
2615
2726
|
"upload_post_auto_upload": upload_post_auto_upload,
|
|
2727
|
+
"postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
|
|
2728
|
+
"postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
|
|
2729
|
+
"postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
|
|
2616
2730
|
})
|
|
2617
2731
|
write_json("settings.json", settings)
|
|
2618
2732
|
try:
|
package/hermes_ui/storage.py
CHANGED
|
@@ -192,6 +192,13 @@ DEFAULTS: dict[str, Any] = {
|
|
|
192
192
|
"upload_post_username": "",
|
|
193
193
|
"upload_post_platforms": "tiktok,instagram",
|
|
194
194
|
"upload_post_auto_upload": False,
|
|
195
|
+
"postiz_enabled": False,
|
|
196
|
+
"postiz_api_key": "",
|
|
197
|
+
"postiz_base_url": "https://api.postiz.com/public/v1",
|
|
198
|
+
"postiz_mcp_url": "https://api.postiz.com/mcp",
|
|
199
|
+
"postiz_mode": "api",
|
|
200
|
+
"postiz_integration_id": "",
|
|
201
|
+
"postiz_auto_publish": False,
|
|
195
202
|
"tiktok_client_key": "",
|
|
196
203
|
"tiktok_client_secret": "",
|
|
197
204
|
"tiktok_redirect_uri": "http://localhost:3030/oauth/tiktok/callback",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Postiz Public API adapter used as the final upload fallback."""
|
|
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_POSTIZ_BASE_URL = "https://api.postiz.com/public/v1"
|
|
15
|
+
DEFAULT_POSTIZ_MCP_URL = "https://api.postiz.com/mcp"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PostizAdapter:
|
|
19
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
20
|
+
self.settings = settings or {}
|
|
21
|
+
self.api_key = str(self.settings.get("postiz_api_key") or "").strip()
|
|
22
|
+
self.base_url = str(self.settings.get("postiz_base_url") or DEFAULT_POSTIZ_BASE_URL).strip().rstrip("/")
|
|
23
|
+
self.mcp_url = str(self.settings.get("postiz_mcp_url") or DEFAULT_POSTIZ_MCP_URL).strip().rstrip("/")
|
|
24
|
+
|
|
25
|
+
def _headers(self, *, json_body: bool = False) -> dict[str, str]:
|
|
26
|
+
headers = {"Authorization": self.api_key}
|
|
27
|
+
if json_body:
|
|
28
|
+
headers["Content-Type"] = "application/json"
|
|
29
|
+
return headers
|
|
30
|
+
|
|
31
|
+
def _error(self, message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
|
|
32
|
+
return IntegrationResult(False, message, data or {})
|
|
33
|
+
|
|
34
|
+
def status(self) -> IntegrationResult:
|
|
35
|
+
if not self.api_key:
|
|
36
|
+
return self._error("Postiz não configurado: adicione a API key em Configurações Técnicas.")
|
|
37
|
+
return IntegrationResult(True, f"Postiz configurado em {self.base_url}.", {"base_url": self.base_url, "mcp_url": self.mcp_url})
|
|
38
|
+
|
|
39
|
+
def list_integrations(self) -> IntegrationResult:
|
|
40
|
+
status = self.status()
|
|
41
|
+
if not status.ok:
|
|
42
|
+
return status
|
|
43
|
+
try:
|
|
44
|
+
response = requests.get(f"{self.base_url}/integrations", headers=self._headers(), timeout=30)
|
|
45
|
+
except requests.RequestException as exc:
|
|
46
|
+
return self._error(f"Não foi possível contactar o Postiz: {exc}")
|
|
47
|
+
if response.status_code >= 400:
|
|
48
|
+
return self._error(f"Postiz devolveu HTTP {response.status_code}: {response.text[:500]}")
|
|
49
|
+
try:
|
|
50
|
+
payload = response.json()
|
|
51
|
+
except ValueError:
|
|
52
|
+
return self._error("Postiz devolveu uma resposta que não é JSON.")
|
|
53
|
+
if isinstance(payload, dict):
|
|
54
|
+
integrations = payload.get("integrations") or payload.get("data") or payload.get("items") or []
|
|
55
|
+
else:
|
|
56
|
+
integrations = payload
|
|
57
|
+
if not isinstance(integrations, list):
|
|
58
|
+
return self._error("A resposta de integrações do Postiz não tem uma lista válida.", {"payload": payload})
|
|
59
|
+
normalized = [item for item in integrations if isinstance(item, dict) and item.get("id")]
|
|
60
|
+
return IntegrationResult(True, f"{len(normalized)} integração(ões) Postiz carregada(s).", {"integrations": normalized, "payload": payload})
|
|
61
|
+
|
|
62
|
+
def upload_file(self, video_path: str | Path, *, mime_type: str | None = None) -> IntegrationResult:
|
|
63
|
+
status = self.status()
|
|
64
|
+
if not status.ok:
|
|
65
|
+
return status
|
|
66
|
+
path = Path(video_path)
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
return self._error(f"Vídeo não encontrado para o Postiz: {path}")
|
|
69
|
+
content_type = mime_type or mimetypes.guess_type(path.name)[0] or "video/mp4"
|
|
70
|
+
try:
|
|
71
|
+
with path.open("rb") as handle:
|
|
72
|
+
response = requests.post(
|
|
73
|
+
f"{self.base_url}/upload",
|
|
74
|
+
headers=self._headers(),
|
|
75
|
+
files={"file": (path.name, handle, content_type)},
|
|
76
|
+
timeout=180,
|
|
77
|
+
)
|
|
78
|
+
except requests.RequestException as exc:
|
|
79
|
+
return self._error(f"Não foi possível enviar o vídeo para o Postiz: {exc}")
|
|
80
|
+
if response.status_code >= 400:
|
|
81
|
+
return self._error(f"Postiz rejeitou o upload (HTTP {response.status_code}): {response.text[:500]}")
|
|
82
|
+
try:
|
|
83
|
+
payload = response.json()
|
|
84
|
+
except ValueError:
|
|
85
|
+
return self._error("Postiz devolveu uma resposta de upload que não é JSON.")
|
|
86
|
+
asset_id = str(payload.get("id") or "").strip() if isinstance(payload, dict) else ""
|
|
87
|
+
asset_path = str(payload.get("path") or "").strip() if isinstance(payload, dict) else ""
|
|
88
|
+
if not asset_id or not asset_path:
|
|
89
|
+
return self._error("O upload Postiz não devolveu id e path do asset.", {"payload": payload})
|
|
90
|
+
return IntegrationResult(True, "Vídeo carregado no Postiz.", {"asset": {"id": asset_id, "path": asset_path}, "payload": payload})
|
|
91
|
+
|
|
92
|
+
def create_youtube_post(
|
|
93
|
+
self,
|
|
94
|
+
integration_id: str,
|
|
95
|
+
*,
|
|
96
|
+
asset: dict[str, str],
|
|
97
|
+
title: str,
|
|
98
|
+
description: str = "",
|
|
99
|
+
visibility: str = "private",
|
|
100
|
+
tags: list[str] | None = None,
|
|
101
|
+
thumbnail: dict[str, str] | None = None,
|
|
102
|
+
post_type: str = "now",
|
|
103
|
+
date: str | None = None,
|
|
104
|
+
) -> IntegrationResult:
|
|
105
|
+
status = self.status()
|
|
106
|
+
if not status.ok:
|
|
107
|
+
return status
|
|
108
|
+
integration_id = str(integration_id or "").strip()
|
|
109
|
+
if not integration_id:
|
|
110
|
+
return self._error("Seleccione uma integração YouTube do Postiz antes de publicar.")
|
|
111
|
+
normalized_visibility = visibility if visibility in {"public", "unlisted", "private"} else "private"
|
|
112
|
+
tag_values = [str(tag).strip() for tag in (tags or []) if str(tag).strip()]
|
|
113
|
+
body: dict[str, Any] = {
|
|
114
|
+
"type": post_type if post_type in {"now", "schedule", "draft"} else "now",
|
|
115
|
+
"shortLink": False,
|
|
116
|
+
"tags": [],
|
|
117
|
+
"posts": [
|
|
118
|
+
{
|
|
119
|
+
"integration": {"id": integration_id},
|
|
120
|
+
"value": [{"content": description, "image": [asset]}],
|
|
121
|
+
"settings": {
|
|
122
|
+
"__type": "youtube",
|
|
123
|
+
"title": title.strip()[:100] or "Vídeo Thunderbolt",
|
|
124
|
+
"type": normalized_visibility,
|
|
125
|
+
"selfDeclaredMadeForKids": "no",
|
|
126
|
+
"thumbnail": thumbnail,
|
|
127
|
+
"tags": [{"value": value, "label": value} for value in tag_values],
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
if post_type == "schedule" and date:
|
|
133
|
+
body["date"] = date
|
|
134
|
+
try:
|
|
135
|
+
response = requests.post(f"{self.base_url}/posts", headers=self._headers(json_body=True), json=body, timeout=60)
|
|
136
|
+
except requests.RequestException as exc:
|
|
137
|
+
return self._error(f"Não foi possível criar o post no Postiz: {exc}")
|
|
138
|
+
if response.status_code >= 400:
|
|
139
|
+
return self._error(f"Postiz rejeitou a publicação (HTTP {response.status_code}): {response.text[:500]}", {"request": body})
|
|
140
|
+
try:
|
|
141
|
+
payload = response.json()
|
|
142
|
+
except ValueError:
|
|
143
|
+
return self._error("Postiz devolveu uma resposta de publicação que não é JSON.", {"request": body})
|
|
144
|
+
return IntegrationResult(True, "Post criado no Postiz.", {"payload": payload, "request": body, "asset": asset, "integration_id": integration_id})
|
|
145
|
+
|
|
146
|
+
def publish_video(
|
|
147
|
+
self,
|
|
148
|
+
video_path: str | Path,
|
|
149
|
+
*,
|
|
150
|
+
integration_id: str,
|
|
151
|
+
title: str,
|
|
152
|
+
description: str = "",
|
|
153
|
+
visibility: str = "private",
|
|
154
|
+
tags: list[str] | None = None,
|
|
155
|
+
thumbnail_path: str | Path | None = None,
|
|
156
|
+
) -> IntegrationResult:
|
|
157
|
+
upload = self.upload_file(video_path)
|
|
158
|
+
if not upload.ok:
|
|
159
|
+
return upload
|
|
160
|
+
thumbnail_asset = None
|
|
161
|
+
if thumbnail_path and Path(thumbnail_path).is_file():
|
|
162
|
+
thumb_upload = self.upload_file(thumbnail_path, mime_type=mimetypes.guess_type(str(thumbnail_path))[0] or "image/jpeg")
|
|
163
|
+
if thumb_upload.ok:
|
|
164
|
+
thumbnail_asset = thumb_upload.data.get("asset")
|
|
165
|
+
return self.create_youtube_post(
|
|
166
|
+
integration_id,
|
|
167
|
+
asset=upload.data["asset"],
|
|
168
|
+
title=title,
|
|
169
|
+
description=description,
|
|
170
|
+
visibility=visibility,
|
|
171
|
+
tags=tags,
|
|
172
|
+
thumbnail=thumbnail_asset,
|
|
173
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Ordered YouTube upload routing for Thunderbolt.
|
|
2
|
+
|
|
3
|
+
The route is intentionally deterministic:
|
|
4
|
+
1. Official YouTube API, up to five successful sends per Google account/day.
|
|
5
|
+
2. Internal browser-session upload, when the account document is complete.
|
|
6
|
+
3. Postiz, when configured with an API key and integration ID.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from integrations.platforms import IntegrationResult, YouTubeAdapter
|
|
16
|
+
from integrations.postiz import PostizAdapter
|
|
17
|
+
from integrations.youtube_direct_credentials import document_status
|
|
18
|
+
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
19
|
+
from hermes_ui.storage import read_json, write_json
|
|
20
|
+
|
|
21
|
+
OFFICIAL_DAILY_LIMIT = 5
|
|
22
|
+
QUOTA_FILENAME = "official_upload_quota.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _attempt_record(name: str, result: IntegrationResult, *, skipped: bool = False) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"route": name,
|
|
28
|
+
"status": "skipped" if skipped else ("success" if result.ok else "failed"),
|
|
29
|
+
"message": result.message,
|
|
30
|
+
"data": result.data,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _quota_key(channel: dict[str, Any], account: dict[str, Any] | None) -> str:
|
|
35
|
+
if account and account.get("id"):
|
|
36
|
+
return f"account:{account['id']}"
|
|
37
|
+
if channel.get("google_account_id"):
|
|
38
|
+
return f"account:{channel['google_account_id']}"
|
|
39
|
+
return f"channel:{channel.get('id', 'unknown')}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def official_upload_count(channel: dict[str, Any], account: dict[str, Any] | None, *, today: str | None = None) -> int:
|
|
43
|
+
today = today or date.today().isoformat()
|
|
44
|
+
state = read_json(QUOTA_FILENAME, {})
|
|
45
|
+
if not isinstance(state, dict):
|
|
46
|
+
return 0
|
|
47
|
+
entry = state.get(_quota_key(channel, account), {})
|
|
48
|
+
if not isinstance(entry, dict) or entry.get("date") != today:
|
|
49
|
+
return 0
|
|
50
|
+
try:
|
|
51
|
+
return max(0, int(entry.get("count", 0)))
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def record_official_upload(channel: dict[str, Any], account: dict[str, Any] | None, *, today: str | None = None) -> int:
|
|
57
|
+
today = today or date.today().isoformat()
|
|
58
|
+
state = read_json(QUOTA_FILENAME, {})
|
|
59
|
+
if not isinstance(state, dict):
|
|
60
|
+
state = {}
|
|
61
|
+
key = _quota_key(channel, account)
|
|
62
|
+
current = official_upload_count(channel, account, today=today)
|
|
63
|
+
state[key] = {"date": today, "count": current + 1}
|
|
64
|
+
write_json(QUOTA_FILENAME, state)
|
|
65
|
+
return current + 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _result_with_attempts(result: IntegrationResult, attempts: list[dict[str, Any]], route: str) -> IntegrationResult:
|
|
69
|
+
data = dict(result.data or {})
|
|
70
|
+
data["route"] = route
|
|
71
|
+
data["attempts"] = attempts
|
|
72
|
+
return IntegrationResult(result.ok, result.message, data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def upload_with_default_route(
|
|
76
|
+
settings: dict[str, Any],
|
|
77
|
+
*,
|
|
78
|
+
storage_root: Path,
|
|
79
|
+
channel: dict[str, Any],
|
|
80
|
+
account: dict[str, Any] | None,
|
|
81
|
+
video_path: str,
|
|
82
|
+
title: str,
|
|
83
|
+
description: str = "",
|
|
84
|
+
tags: list[str] | None = None,
|
|
85
|
+
category_id: str = "22",
|
|
86
|
+
language: str = "pt-BR",
|
|
87
|
+
privacy_status: str = "private",
|
|
88
|
+
thumbnail_path: str = "",
|
|
89
|
+
captions_path: str = "",
|
|
90
|
+
official_uploader: Callable[..., IntegrationResult] | None = None,
|
|
91
|
+
direct_uploader: Callable[..., IntegrationResult] | None = None,
|
|
92
|
+
postiz_publisher: Callable[..., IntegrationResult] | None = None,
|
|
93
|
+
) -> IntegrationResult:
|
|
94
|
+
attempts: list[dict[str, Any]] = []
|
|
95
|
+
quota_count = official_upload_count(channel, account)
|
|
96
|
+
if quota_count >= OFFICIAL_DAILY_LIMIT:
|
|
97
|
+
quota_result = IntegrationResult(False, f"Limite local de {OFFICIAL_DAILY_LIMIT} envios oficiais por conta Google atingido hoje; a tentar o próximo método.", {"count": quota_count, "limit": OFFICIAL_DAILY_LIMIT})
|
|
98
|
+
attempts.append(_attempt_record("API Oficial", quota_result, skipped=True))
|
|
99
|
+
else:
|
|
100
|
+
official_uploader = official_uploader or YouTubeAdapter(settings).upload_video
|
|
101
|
+
try:
|
|
102
|
+
official_result = official_uploader(
|
|
103
|
+
video_path,
|
|
104
|
+
title=title,
|
|
105
|
+
description=description,
|
|
106
|
+
tags=tags or [],
|
|
107
|
+
category_id=category_id,
|
|
108
|
+
language=language,
|
|
109
|
+
privacy_status=privacy_status,
|
|
110
|
+
thumbnail_path=thumbnail_path,
|
|
111
|
+
captions_path=captions_path,
|
|
112
|
+
)
|
|
113
|
+
except Exception as exc: # Keep fallback actionable and deterministic.
|
|
114
|
+
official_result = IntegrationResult(False, f"API Oficial falhou: {exc}", {})
|
|
115
|
+
attempts.append(_attempt_record("API Oficial", official_result))
|
|
116
|
+
if official_result.ok:
|
|
117
|
+
used = record_official_upload(channel, account)
|
|
118
|
+
data = dict(official_result.data or {})
|
|
119
|
+
data["official_daily_count"] = used
|
|
120
|
+
data["official_daily_limit"] = OFFICIAL_DAILY_LIMIT
|
|
121
|
+
data["route"] = "API Oficial"
|
|
122
|
+
data["attempts"] = attempts
|
|
123
|
+
return IntegrationResult(True, official_result.message, data)
|
|
124
|
+
|
|
125
|
+
direct_ready = False
|
|
126
|
+
if account:
|
|
127
|
+
try:
|
|
128
|
+
status = document_status(storage_root, account, channel, settings, [channel])
|
|
129
|
+
direct_ready = bool(status.get("ready"))
|
|
130
|
+
if not direct_ready:
|
|
131
|
+
missing = list(status.get("missing_cookies", []))
|
|
132
|
+
if not status.get("has_session_info"):
|
|
133
|
+
missing.append("sessionInfo")
|
|
134
|
+
if not status.get("has_innertube_api_key"):
|
|
135
|
+
missing.append("INNERTUBE_API_KEY")
|
|
136
|
+
if not status.get("has_delegated_session_id"):
|
|
137
|
+
missing.append("DELEGATED_SESSION_ID")
|
|
138
|
+
direct_result = IntegrationResult(False, f"Upload directo indisponível: {', '.join(missing)}.", {"missing": missing})
|
|
139
|
+
else:
|
|
140
|
+
direct_result = None
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
direct_result = IntegrationResult(False, f"Não foi possível validar o documento do Upload directo: {exc}", {})
|
|
143
|
+
else:
|
|
144
|
+
direct_result = IntegrationResult(False, "Upload directo indisponível: o canal não tem uma conta Google associada.", {})
|
|
145
|
+
if direct_ready:
|
|
146
|
+
direct_uploader = direct_uploader or YouTubeDirectUploader(settings, channel, account=account, storage_root=storage_root).upload
|
|
147
|
+
try:
|
|
148
|
+
direct_result = direct_uploader(video_path, title=title, description=description, visibility=privacy_status)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
direct_result = IntegrationResult(False, f"Upload directo falhou: {exc}", {})
|
|
151
|
+
attempts.append(_attempt_record("Upload directo", direct_result))
|
|
152
|
+
if direct_result.ok:
|
|
153
|
+
return _result_with_attempts(direct_result, attempts, "Upload directo")
|
|
154
|
+
|
|
155
|
+
postiz = PostizAdapter(settings)
|
|
156
|
+
if not bool(settings.get("postiz_enabled", False)):
|
|
157
|
+
postiz_result = IntegrationResult(False, "Postiz está desactivado em Configurações Técnicas.", {})
|
|
158
|
+
attempts.append(_attempt_record("Postiz", postiz_result, skipped=True))
|
|
159
|
+
else:
|
|
160
|
+
postiz_publisher = postiz_publisher or postiz.publish_video
|
|
161
|
+
try:
|
|
162
|
+
postiz_result = postiz_publisher(
|
|
163
|
+
video_path,
|
|
164
|
+
integration_id=str(settings.get("postiz_integration_id", "") or ""),
|
|
165
|
+
title=title,
|
|
166
|
+
description=description,
|
|
167
|
+
visibility=privacy_status,
|
|
168
|
+
tags=tags or [],
|
|
169
|
+
thumbnail_path=thumbnail_path,
|
|
170
|
+
)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
postiz_result = IntegrationResult(False, f"Postiz falhou: {exc}", {})
|
|
173
|
+
attempts.append(_attempt_record("Postiz", postiz_result))
|
|
174
|
+
if postiz_result.ok:
|
|
175
|
+
return _result_with_attempts(postiz_result, attempts, "Postiz")
|
|
176
|
+
|
|
177
|
+
return IntegrationResult(False, "Nenhum método de envio conseguiu publicar o vídeo.", {"attempts": attempts, "route": "none"})
|
package/package.json
CHANGED