@danhachuel/thunderbolt 0.2.16 → 0.2.18

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.16
5
+ > **Versão deste manual:** 0.2.18
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.16 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.18 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.16 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.18 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.
@@ -345,7 +345,11 @@ Na primeira execução, abra **Configurações** e reveja:
345
345
  |---|---|
346
346
  | Porta Streamlit | Definir a porta local da UI |
347
347
  | Pasta MoneyPrinterTurbo | Indicar o clone local que será sincronizado com `config.toml` |
348
- | YouTube Data API key | Permitir importar nome, handle e métricas de canais |
348
+ | YouTube Data API Key | Permitir importar nome, handle e métricas públicas de canais |
349
+ | YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para operações autenticadas |
350
+ | YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é a Data API Key |
351
+ | YouTube upload principal | Lógica do `youtube-automation-agent` adaptada e executada dentro do Thunderbolt |
352
+ | OAuth directo de redundância | Caminho alternativo accionado automaticamente se o agente falhar |
349
353
  | TikTok Client ID/Secret | Credenciais da aplicação; Redirect URI, scopes e autorização ficam no TikTok for Developers Playground |
350
354
 
351
355
  As chaves devem ser inseridas apenas na configuração local. Não as coloque no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
@@ -357,10 +361,10 @@ Após iniciar a aplicação, valide o seguinte percurso:
357
361
  1. **Dashboard:** confirme que a UI abre e mostra o estado local.
358
362
  2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
359
363
  3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
360
- 4. **Canais:** cadastre um canal manualmente ou importe-o depois de configurar a YouTube Data API.
364
+ 4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** ou a Data API opcional; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
361
365
  5. **Novo vídeo:** teste primeiro o modo **Canal específico** e depois os modos de lote.
362
- 6. **Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar**.
363
- 7. **Upload:** confirme os destinos configurados e os estados de pré-requisito.
366
+ 6. **Novo vídeo > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba.
367
+ 7. **Upload:** autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key é exclusivamente para consultas públicas.
364
368
  8. **Limpador de metadado:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos da aba Novo vídeo não são aceites nesta área.
365
369
  9. **Configurações:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
366
370
 
@@ -504,6 +508,8 @@ Nunca publique ou envie por chat:
504
508
 
505
509
  Use secrets do GitHub Actions apenas para publicação do pacote. Para execução local, mantenha credenciais fora do repositório e use a área de configurações local ou variáveis de ambiente.
506
510
 
511
+ A publicação YouTube segue primeiro a lógica adaptada do `PublishingSchedulingAgent` do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent), sob licença MIT. O código corre dentro do processo Streamlit; não é necessário iniciar o agente Node separadamente. O fallback OAuth directo só é chamado quando a tentativa primária falha.
512
+
507
513
  ## 14. Referências
508
514
 
509
515
  [1]: https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md — MoneyPrinterTurbo: requisitos, instalação, dependências, Streamlit e FFmpeg.
package/README.md CHANGED
@@ -14,10 +14,9 @@ A primeira versão implementa a camada UI independente com:
14
14
  | Pipeline | Filas por etapa |
15
15
  | Blueprints | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
16
16
  | Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
17
- | Canais | Cadastro manual, importação via YouTube Data API quando configurada e edição de dados importados |
18
- | Novo vídeo | Canal específico, lote no mesmo canal e lote geral |
19
- | Vídeos | Filtro por estado e controlos iniciar/parar |
20
- | Upload | Destinos YouTube/TikTok e diagnóstico do TikTok |
17
+ | Canais | Subabas de importação pública sem API Key, Data API opcional e cadastro manual independente |
18
+ | Novo vídeo | Subabas Criar vídeo e Vídeos; canal específico, lote no mesmo canal, lote geral, filtro de estados e controlos iniciar/parar |
19
+ | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, TikTok e diagnóstico |
21
20
  | Limpador de metadado | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
22
21
  | Configurações | Provedores LLM, TTS/voz, materiais, Whisper, FFmpeg, YouTube, TikTok Client ID/Secret e Upload-Post |
23
22
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
@@ -103,9 +102,9 @@ thunderbolt
103
102
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
104
103
 
105
104
  ```powershell
106
- npx.cmd --yes @danhachuel/thunderbolt@0.2.16 install
107
- npx.cmd --yes @danhachuel/thunderbolt@0.2.16 doctor
108
- npx.cmd --yes @danhachuel/thunderbolt@0.2.16
105
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.18 install
106
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.18 doctor
107
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.18
109
108
  ```
110
109
 
111
110
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -162,12 +161,25 @@ A descrição segue a composição do workflow de referência: preview, secção
162
161
 
163
162
  ## Canais YouTube
164
163
 
165
- Sem chave, o cadastro manual continua disponível. Para importar nome, handle e estatísticas através da API oficial do YouTube Data API, configure `youtube_api_key` na aba **Configurações** ou em `YOUTUBE_API_KEY`.
164
+ A aba **Canais** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API API Key opcional** pode ser escolhida para métricas oficiais quando `youtube_api_key` estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`.
165
+
166
+ Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo e Blueprint associado. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar.
167
+
168
+ ## Upload YouTube
169
+
170
+ 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.
171
+
172
+ Na aba **Upload**, configure primeiro o **YouTube OAuth Client ID** e o **YouTube OAuth Client Secret** em **Configurações**. Em seguida, use **Autorizar agente YouTube**. O navegador local abrirá a autorização Google e o token será guardado apenas em `storage/state/youtube_agent_tokens.json`. Se a publicação principal falhar, o Thunderbolt tenta automaticamente o **OAuth directo de redundância**, usando o token local compatível; a Data API Key nunca é usada para publicar.
173
+
174
+ 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.
166
175
 
167
176
  ## TikTok
168
177
 
169
- A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret. Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
178
+ A subaba **Vídeos**, dentro de **Novo vídeo**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
179
+
180
+ A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret.
181
+ Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
170
182
 
171
183
  ## Segurança
172
184
 
173
- Não coloque chaves, cookies, tokens YouTube ou segredos TikTok no Git. Use a configuração local, variáveis de ambiente ou um ficheiro fora do repositório. O `.gitignore` exclui o storage de estado real, ambientes virtuais e ficheiros de segredo.
185
+ Não coloque chaves, cookies, tokens YouTube ou segredos TikTok no Git. Use a configuração local, variáveis de ambiente ou um ficheiro fora do repositório. O `.gitignore` exclui o storage de estado real, ambientes virtuais e ficheiros de segredo. O adaptador interno foi baseado na lógica publicada sob licença MIT do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent).
package/app/main.py CHANGED
@@ -18,7 +18,7 @@ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_
18
18
  from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
19
19
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
20
20
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
21
- from integrations.platforms import TikTokAdapter, YouTubeAdapter
21
+ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
22
22
  from integrations.local_runtime import MoneyPrinterRuntime
23
23
  from integrations.moneyprinter_config import sync_moneyprinter_config
24
24
 
@@ -194,38 +194,107 @@ def render_blueprints():
194
194
 
195
195
  def render_channels():
196
196
  st.title("Canais")
197
- st.caption("Importação do YouTube com edição manual e armazenamento local")
198
- with st.expander("Criar ou importar novo canal", expanded=True):
199
- source = st.text_input("Nome, URL, handle ou ID do canal", placeholder="https://youtube.com/@seucanal")
197
+ st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
198
+ settings = read_json("settings.json", {})
199
+ youtube = YouTubeAdapter(settings=settings)
200
+ import_tab, manual_tab = st.tabs(["Importar do YouTube", "Cadastro manual"])
201
+
202
+ with import_tab:
203
+ st.caption("A pesquisa pública funciona sem API Key. A Data API é opcional e fica disponível numa opção separada para métricas oficiais.")
204
+ source = st.text_input("URL, handle ou ID do canal", placeholder="https://youtube.com/@seucanal", key="youtube_channel_source")
205
+ lookup_mode = st.radio("Método de consulta", ["Página pública — sem API Key", "YouTube Data API — API Key opcional"], horizontal=True, key="youtube_channel_lookup_mode")
200
206
  col1, col2 = st.columns([1, 1])
201
207
  with col1:
202
- if st.button("Buscar no YouTube", type="primary", use_container_width=True):
203
- result = YouTubeAdapter(read_json("settings.json", {}).get("youtube_api_key", "")).fetch_channel(source)
208
+ if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
209
+ if lookup_mode.startswith("Página pública"):
210
+ result = youtube.fetch_channel_public(source)
211
+ elif not youtube.api_key:
212
+ result = IntegrationResult(False, "A YouTube Data API Key não está configurada. Escolha a opção Página pública — sem API Key ou configure a chave em Configurações.", {"status": "api_key_not_configured"})
213
+ else:
214
+ result = youtube.fetch_channel(source)
204
215
  st.session_state["yt_import"] = result.data
205
216
  st.session_state["yt_message"] = result.message
206
217
  st.session_state["yt_ok"] = result.ok
207
218
  with col2:
208
- if st.button("Limpar importação", use_container_width=True):
209
- st.session_state.pop("yt_import", None)
219
+ if st.button("Limpar importação", use_container_width=True, key="youtube_channel_clear"):
220
+ for key in ("yt_import", "yt_message", "yt_ok"):
221
+ st.session_state.pop(key, None)
222
+ st.rerun()
210
223
  if st.session_state.get("yt_message"):
211
224
  (st.success if st.session_state.get("yt_ok") else st.warning)(st.session_state["yt_message"])
212
225
  imported = st.session_state.get("yt_import", {})
213
- with st.form("channel_form"):
214
- name = st.text_input("Nome do canal", value=imported.get("name", ""))
215
- url = st.text_input("URL", value=source if source.startswith("http") else imported.get("url", ""))
216
- handle = st.text_input("Handle", value=imported.get("handle", ""))
217
- language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0)
218
- style = st.selectbox("Estilo wide", ["pexels", "full_ia"], index=0)
219
- blueprint = st.text_input("Blueprint associado", value="")
220
- submitted = st.form_submit_button("Guardar canal", type="primary")
226
+ if imported:
227
+ st.caption("Dados encontrados. Reveja ou edite os campos antes de guardar.")
228
+ with st.form("channel_import_form"):
229
+ name = st.text_input("Nome do canal", value=imported.get("name", ""), key="yt_import_name")
230
+ url = st.text_input("URL", value=imported.get("url", source if source.startswith("http") else ""), key="yt_import_url")
231
+ handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
232
+ language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="yt_import_language")
233
+ style = st.selectbox("Estilo wide", ["pexels", "full_ia"], index=0, key="yt_import_style")
234
+ blueprint = st.text_input("Blueprint associado", value="", key="yt_import_blueprint")
235
+ description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
236
+ metrics = st.columns(3)
237
+ with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
238
+ with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=int(imported.get("video_count") or 0), key="yt_import_videos")
239
+ with metrics[2]: view_count = st.number_input("Visualizações", min_value=0, value=int(imported.get("view_count") or 0), key="yt_import_views")
240
+ submitted = st.form_submit_button("Guardar canal importado", type="primary")
241
+ if submitted:
242
+ if not name.strip():
243
+ st.error("Informe o nome do canal antes de guardar.")
244
+ else:
245
+ metadata = {
246
+ **imported,
247
+ "handle": handle.strip(),
248
+ "description": description.strip(),
249
+ "language": language,
250
+ "style_wide": style,
251
+ "blueprint_id": blueprint.strip(),
252
+ "subscriber_count": int(subscriber_count) or None,
253
+ "video_count": int(video_count) or None,
254
+ "view_count": int(view_count) or None,
255
+ "metrics_source": imported.get("metrics_source", "youtube_public_page"),
256
+ }
257
+ channel = create_channel(name, url, metadata)
258
+ st.success(f"Canal {channel['name']} guardado.")
259
+ st.rerun()
260
+ else:
261
+ st.info("Introduza um URL, handle ou ID e clique em Buscar no YouTube. Não é necessária API Key na opção pública.")
262
+
263
+ with manual_tab:
264
+ st.caption("Este fluxo não consulta o YouTube e não exige API Key. Preencha os dados que quiser e guarde o canal localmente.")
265
+ with st.form("channel_manual_form"):
266
+ name = st.text_input("Nome do canal", key="manual_channel_name")
267
+ url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
268
+ handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
269
+ description = st.text_area("Descrição", key="manual_channel_description")
270
+ language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
271
+ style = st.selectbox("Estilo wide", ["pexels", "full_ia"], index=0, key="manual_channel_style")
272
+ blueprint = st.text_input("Blueprint associado", key="manual_channel_blueprint")
273
+ thumbnail_url = st.text_input("URL da imagem do canal", key="manual_channel_thumbnail")
274
+ metrics = st.columns(3)
275
+ with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=0, key="manual_channel_subscribers")
276
+ with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=0, key="manual_channel_videos")
277
+ with metrics[2]: view_count = st.number_input("Visualizações", min_value=0, value=0, key="manual_channel_views")
278
+ submitted = st.form_submit_button("Guardar canal manual", type="primary")
221
279
  if submitted:
222
280
  if not name.strip():
223
281
  st.error("Informe o nome do canal.")
224
282
  else:
225
- metadata = {"handle": handle, "language": language, "style_wide": style, "blueprint_id": blueprint, **imported}
226
- channel = create_channel(name, url, metadata)
227
- st.success(f"Canal {channel['name']} guardado.")
283
+ channel = create_channel(name, url, {
284
+ "handle": handle.strip(),
285
+ "description": description.strip(),
286
+ "language": language,
287
+ "style_wide": style,
288
+ "blueprint_id": blueprint.strip(),
289
+ "thumbnail_url": thumbnail_url.strip(),
290
+ "subscriber_count": int(subscriber_count) or None,
291
+ "video_count": int(video_count) or None,
292
+ "view_count": int(view_count) or None,
293
+ "metrics_source": "manual",
294
+ })
295
+ st.success(f"Canal {channel['name']} guardado manualmente.")
228
296
  st.rerun()
297
+
229
298
  st.subheader("Canais cadastrados")
230
299
  channels = read_json("channels.json", [])
231
300
  if not channels:
@@ -241,7 +310,7 @@ def render_channels():
241
310
  st.markdown("### YT")
242
311
  with cols[1]:
243
312
  st.write(f"**{channel.get('name', 'Sem nome')}**")
244
- st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'}")
313
+ st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'} · {channel.get('metrics_source', 'manual')}")
245
314
  with cols[2]: st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
246
315
  with cols[3]: st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
247
316
  with cols[4]: st.metric("Backlog", channel.get("backlog_total", 0))
@@ -253,41 +322,46 @@ def render_channels():
253
322
 
254
323
  def render_new_video():
255
324
  st.title("Novo vídeo")
256
- channels = channel_options()
257
- if not channels:
258
- st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
259
- return
260
- mode_label = st.radio("Modo de criação", ["Canal específico", "Lote no mesmo canal", "Lote geral"], horizontal=True)
261
- mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
262
- if mode == "general":
263
- selected = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid))
264
- else:
265
- selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"])
266
- selected = [selected_one["id"]]
267
- with st.form("new_video_form"):
268
- topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
269
- quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
270
- language = st.selectbox("Idioma", ["Português", "English", "Español"], key="video_language")
271
- fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
272
- style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
273
- submitted = st.form_submit_button("Criar tarefas", type="primary")
274
- if submitted:
275
- if not topic.strip() or not selected:
276
- st.error("Informe um tópico e seleccione pelo menos um canal.")
325
+ create_tab, videos_tab = st.tabs(["Criar vídeo", "Vídeos"])
326
+ with create_tab:
327
+ channels = channel_options()
328
+ if not channels:
329
+ st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
277
330
  else:
278
- quantity = int(quantity if mode == "same_channel" else 1)
279
- batch = create_batch(mode, selected, topic, quantity, {"language": language, "format": fmt, "style_wide": style})
280
- tasks = create_tasks_for_batch(batch)
281
- st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s).")
331
+ mode_label = st.radio("Modo de criação", ["Canal específico", "Lote no mesmo canal", "Lote geral"], horizontal=True, key="new_video_mode")
332
+ mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
333
+ if mode == "general":
334
+ selected = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid), key="new_video_channels_general")
335
+ else:
336
+ selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"], key="new_video_channel")
337
+ selected = [selected_one["id"]]
338
+ with st.form("new_video_form"):
339
+ topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
340
+ quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
341
+ language = st.selectbox("Idioma", ["Português", "English", "Español"], key="video_language")
342
+ fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
343
+ style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
344
+ submitted = st.form_submit_button("Criar tarefas", type="primary")
345
+ if submitted:
346
+ if not topic.strip() or not selected:
347
+ st.error("Informe um tópico e seleccione pelo menos um canal.")
348
+ else:
349
+ quantity = int(quantity if mode == "same_channel" else 1)
350
+ batch = create_batch(mode, selected, topic, quantity, {"language": language, "format": fmt, "style_wide": style})
351
+ tasks = create_tasks_for_batch(batch)
352
+ st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
353
+ with videos_tab:
354
+ render_videos()
282
355
 
283
356
 
284
357
  def render_videos():
285
- st.title("Vídeos e backlog")
358
+ st.subheader("Vídeos e backlog")
359
+ st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
286
360
  tasks = read_json("tasks.json", [])
287
361
  if not tasks:
288
362
  st.info("Nenhum vídeo criado.")
289
363
  return
290
- state_filter = st.selectbox("Filtrar por estado", ["Todos", "to_do", "doing", "blocked", "done", "failed", "cancelled"])
364
+ state_filter = st.selectbox("Filtrar por estado", ["Todos", "to_do", "doing", "blocked", "done", "failed", "cancelled"], key="videos_state_filter")
291
365
  for task in tasks:
292
366
  if state_filter != "Todos" and task.get("state") != state_filter:
293
367
  continue
@@ -311,10 +385,32 @@ def render_videos():
311
385
 
312
386
  def render_upload():
313
387
  st.title("Upload")
388
+ settings = read_json("settings.json", {})
389
+ youtube = YouTubeAdapter(settings=settings)
314
390
  tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
315
391
  destination = st.multiselect("Destinos", ["YouTube", "TikTok"], default=["YouTube"])
392
+
393
+ if "YouTube" in destination:
394
+ st.markdown("**YouTube — youtube-automation-agent (principal)**")
395
+ st.caption("O Thunderbolt executa internamente a lógica do PublishingSchedulingAgent: OAuth, upload resumível, metadados, thumbnail e legendas. O OAuth directo é usado apenas como redundância se o caminho principal falhar.")
396
+ status = youtube.upload_status()
397
+ status_cols = st.columns(2)
398
+ with status_cols[0]:
399
+ (st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
400
+ if st.button("Autorizar agente YouTube", key="youtube_authorize_agent", use_container_width=True):
401
+ result = youtube.authorize_agent()
402
+ (st.success if result.ok else st.error)(result.message)
403
+ if result.ok:
404
+ st.rerun()
405
+ with status_cols[1]:
406
+ (st.info if status["fallback"].ok else st.caption)(f"Fallback OAuth: {status['fallback'].message}")
407
+ if st.button("Autorizar fallback OAuth", key="youtube_authorize_fallback", use_container_width=True):
408
+ result = youtube.authorize_fallback()
409
+ (st.success if result.ok else st.error)(result.message)
410
+ if result.ok:
411
+ st.rerun()
316
412
  if "TikTok" in destination:
317
- status = TikTokAdapter(read_json("settings.json", {})).status()
413
+ status = TikTokAdapter(settings).status()
318
414
  (st.success if status.ok else st.warning)(status.message)
319
415
  if not tasks:
320
416
  st.info("Não há vídeos prontos para upload.")
@@ -322,14 +418,53 @@ def render_upload():
322
418
  for task in tasks:
323
419
  with st.container(border=True):
324
420
  st.write(f"**{task.get('topic')}** — {task.get('channel_name')}")
325
- video_path = task.get("artifacts", {}).get("video", "")
421
+ artifacts = task.get("artifacts", {}) or {}
422
+ video_path = artifacts.get("video", "")
423
+ thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
424
+ captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
326
425
  st.caption(video_path or "Sem caminho de vídeo registado")
327
- if st.button("Preparar upload", key=f"upload_{task['id']}"):
328
- if "TikTok" in destination:
329
- result = TikTokAdapter(read_json("settings.json", {})).upload_video(video_path, task.get("topic", ""))
330
- (st.success if result.ok else st.warning)(result.message)
331
- else:
332
- st.info("Upload YouTube preparado; configure o uploader local para executar a publicação.")
426
+ if "YouTube" in destination:
427
+ title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
428
+ description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
429
+ tags_raw = st.text_input("Tags separadas por vírgula", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"yt_tags_{task['id']}")
430
+ yt_cols = st.columns(3)
431
+ with yt_cols[0]:
432
+ privacy_status = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"yt_privacy_{task['id']}")
433
+ with yt_cols[1]:
434
+ category_id = st.text_input("Category ID", value="22", key=f"yt_category_{task['id']}")
435
+ with yt_cols[2]:
436
+ language = st.text_input("Idioma", value="pt-BR", key=f"yt_language_{task['id']}")
437
+ if st.button("Publicar no YouTube", type="primary", key=f"upload_youtube_{task['id']}"):
438
+ tags = [tag.strip() for tag in tags_raw.split(",") if tag.strip()]
439
+ result = youtube.upload_video(
440
+ video_path,
441
+ title=title,
442
+ description=description,
443
+ tags=tags,
444
+ category_id=category_id,
445
+ language=language,
446
+ privacy_status=privacy_status,
447
+ thumbnail_path=thumbnail_path,
448
+ captions_path=captions_path,
449
+ )
450
+ record = {
451
+ "task_id": task.get("id"),
452
+ "destination": "YouTube",
453
+ "status": "published" if result.ok else "failed",
454
+ "message": result.message,
455
+ "data": result.data,
456
+ "created_at": now(),
457
+ }
458
+ uploads = read_json("uploads.json", [])
459
+ uploads.append(record)
460
+ write_json("uploads.json", uploads)
461
+ (st.success if result.ok else st.error)(result.message)
462
+ if result.data.get("attempts"):
463
+ with st.expander("Detalhes dos mecanismos de upload"):
464
+ st.json(result.data["attempts"])
465
+ if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}"):
466
+ result = TikTokAdapter(settings).upload_video(video_path, task.get("topic", ""))
467
+ (st.success if result.ok else st.warning)(result.message)
333
468
 
334
469
 
335
470
  def render_settings():
@@ -350,7 +485,15 @@ def render_settings():
350
485
  st.subheader("Execução local")
351
486
  port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
352
487
  moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
353
- youtube_api_key = st.text_input("YouTube Data API key", settings.get("youtube_api_key", ""), type="password")
488
+ st.markdown("**YouTube API pública e OAuth 2.0**")
489
+ st.caption("A Data API Key consulta canais e estatísticas públicas. O Client ID e o Client Secret são credenciais OAuth 2.0 separadas, usadas para autorizar operações na conta, como upload.")
490
+ youtube_cols = st.columns(3)
491
+ with youtube_cols[0]:
492
+ youtube_api_key = text_setting("YouTube Data API Key", "youtube_api_key", secret=True, help_text="Chave criada em Google Cloud > APIs e serviços > Credenciais > Chave de API.")
493
+ with youtube_cols[1]:
494
+ youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud.")
495
+ with youtube_cols[2]:
496
+ youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0.")
354
497
 
355
498
  with st.expander("Serviço, materiais e rede"):
356
499
  cols = st.columns(2)
@@ -448,6 +591,7 @@ def render_settings():
448
591
  if st.form_submit_button("Guardar configurações do Thunderbolt", type="primary"):
449
592
  settings.update({
450
593
  "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
594
+ "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
451
595
  "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
452
596
  "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
453
597
  "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
@@ -611,12 +755,14 @@ def main():
611
755
  ("Blueprints", ":material/library_books:", "Blueprints"),
612
756
  ("Canais", ":material/ondemand_video:", "Canais"),
613
757
  ("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
614
- ("Vídeos", ":material/video_library:", "Vídeos"),
615
758
  ("Upload", ":material/cloud_upload:", "Upload"),
616
759
  ("Limpador de metadado", ":material/edit_note:", "Limpador de metadado"),
617
760
  ("Configurações", ":material/settings:", "Configurações"),
618
761
  ]
619
762
  current_page = st.session_state.get("page", "Dashboard")
763
+ if current_page == "Vídeos":
764
+ current_page = "Novo vídeo"
765
+ st.session_state["page"] = current_page
620
766
  with st.sidebar:
621
767
  version_markup = f'<span class="tb-brand-version">{APP_VERSION}</span>' if APP_VERSION else ""
622
768
  st.markdown(f'<div class="tb-brand"><span class="tb-brand-name">Thunderbolt</span>{version_markup}</div>', unsafe_allow_html=True)
@@ -630,7 +776,6 @@ def main():
630
776
  "Blueprints": render_blueprints,
631
777
  "Canais": render_channels,
632
778
  "Novo vídeo": render_new_video,
633
- "Vídeos": render_videos,
634
779
  "Upload": render_upload,
635
780
  "Limpador de metadado": render_metadata_cleaner,
636
781
  "Configurações": render_settings,
@@ -29,6 +29,8 @@ DEFAULTS: dict[str, Any] = {
29
29
  "video_concurrency": 3,
30
30
  "upload_concurrency": 2,
31
31
  "youtube_api_key": "",
32
+ "youtube_client_id": "",
33
+ "youtube_client_secret": "",
32
34
  "llm_provider": "moonshot",
33
35
  "moonshot_api_key": "",
34
36
  "moonshot_base_url": "",
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import re
5
6
  from dataclasses import dataclass
@@ -9,6 +10,102 @@ from typing import Any
9
10
  import requests
10
11
 
11
12
 
13
+ def _extract_json_assignment(document: str, variable: str) -> dict[str, Any] | None:
14
+ marker = re.search(rf"(?:var\s+)?{re.escape(variable)}\s*=", document)
15
+ if not marker:
16
+ return None
17
+ start = document.find("{", marker.end())
18
+ if start < 0:
19
+ return None
20
+ depth = 0
21
+ in_string = False
22
+ escaped = False
23
+ for index in range(start, len(document)):
24
+ character = document[index]
25
+ if in_string:
26
+ if escaped:
27
+ escaped = False
28
+ elif character == "\\":
29
+ escaped = True
30
+ elif character == '"':
31
+ in_string = False
32
+ continue
33
+ if character == '"':
34
+ in_string = True
35
+ elif character == "{":
36
+ depth += 1
37
+ elif character == "}":
38
+ depth -= 1
39
+ if depth == 0:
40
+ try:
41
+ parsed = json.loads(document[start:index + 1])
42
+ except json.JSONDecodeError:
43
+ return None
44
+ return parsed if isinstance(parsed, dict) else None
45
+ return None
46
+
47
+
48
+ def _find_first_key(value: Any, key: str) -> Any:
49
+ if isinstance(value, dict):
50
+ if key in value:
51
+ return value[key]
52
+ for child in value.values():
53
+ found = _find_first_key(child, key)
54
+ if found is not None:
55
+ return found
56
+ elif isinstance(value, list):
57
+ for child in value:
58
+ found = _find_first_key(child, key)
59
+ if found is not None:
60
+ return found
61
+ return None
62
+
63
+
64
+ def _text_from_node(value: Any) -> str:
65
+ if isinstance(value, str):
66
+ return value.strip()
67
+ if isinstance(value, dict):
68
+ for key in ("simpleText", "content", "text"):
69
+ if isinstance(value.get(key), str):
70
+ return value[key].strip()
71
+ runs = value.get("runs")
72
+ if isinstance(runs, list):
73
+ return "".join(str(run.get("text", "")) for run in runs if isinstance(run, dict)).strip()
74
+ return ""
75
+
76
+
77
+ def _parse_public_count(value: Any) -> int | None:
78
+ text = _text_from_node(value).lower().replace(" ", "")
79
+ if not text:
80
+ return None
81
+ match = re.search(r"([0-9][0-9.,]*)(mil|milhões|mi|bi|[kmb])?", text)
82
+ if not match:
83
+ return None
84
+ number = match.group(1)
85
+ suffix = match.group(2) or ""
86
+ if suffix == "milhões":
87
+ suffix = "m"
88
+ try:
89
+ if suffix in {"k", "mil"}:
90
+ return int(float(number.replace(",", ".")) * 1_000)
91
+ if suffix in {"m", "mi"}:
92
+ return int(float(number.replace(",", ".")) * 1_000_000)
93
+ if suffix == "b":
94
+ return int(float(number.replace(",", ".")) * 1_000_000_000)
95
+ return int(re.sub(r"[^0-9]", "", number))
96
+ except ValueError:
97
+ return None
98
+
99
+
100
+ def _thumbnail_from_metadata(metadata: dict[str, Any]) -> str:
101
+ thumbnails = metadata.get("avatar", {}).get("thumbnails", []) if isinstance(metadata, dict) else []
102
+ if isinstance(thumbnails, list) and thumbnails:
103
+ last = thumbnails[-1]
104
+ if isinstance(last, dict):
105
+ return str(last.get("url", ""))
106
+ return ""
107
+
108
+
12
109
  @dataclass
13
110
  class IntegrationResult:
14
111
  ok: bool
@@ -17,8 +114,9 @@ class IntegrationResult:
17
114
 
18
115
 
19
116
  class YouTubeAdapter:
20
- def __init__(self, api_key: str = ""):
21
- self.api_key = api_key or os.getenv("YOUTUBE_API_KEY", "")
117
+ def __init__(self, api_key: str = "", settings: dict[str, Any] | None = None):
118
+ self.settings = settings or {}
119
+ self.api_key = api_key or self.settings.get("youtube_api_key", "") or os.getenv("YOUTUBE_API_KEY", "")
22
120
 
23
121
  @staticmethod
24
122
  def extract_channel_ref(value: str) -> str:
@@ -26,6 +124,72 @@ class YouTubeAdapter:
26
124
  match = re.search(r"(?:channel/|@)([A-Za-z0-9_.-]+)", value)
27
125
  return match.group(1) if match else value
28
126
 
127
+ def fetch_channel_public(self, value: str) -> IntegrationResult:
128
+ """Fetch public channel metadata from YouTube HTML without an API key."""
129
+ source = (value or "").strip()
130
+ if not source:
131
+ return IntegrationResult(False, "Introduza o nome, handle, URL ou ID do canal.", {})
132
+ try:
133
+ if source.startswith("http://") or source.startswith("https://"):
134
+ page_url = source.rstrip("/")
135
+ elif source.startswith("UC"):
136
+ page_url = f"https://www.youtube.com/channel/{source}"
137
+ else:
138
+ handle = source if source.startswith("@") else f"@{source}"
139
+ page_url = f"https://www.youtube.com/{handle}"
140
+ if not page_url.endswith("/about"):
141
+ page_url = f"{page_url}/about"
142
+ response = requests.get(
143
+ page_url,
144
+ headers={
145
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
146
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
147
+ },
148
+ timeout=20,
149
+ )
150
+ response.raise_for_status()
151
+ document = response.text
152
+ initial_data = _extract_json_assignment(document, "ytInitialData")
153
+ metadata = _find_first_key(initial_data or {}, "channelMetadataRenderer") or {}
154
+ header = _find_first_key(initial_data or {}, "pageHeaderViewModel") or {}
155
+ if not metadata and not header:
156
+ return IntegrationResult(False, "O YouTube não disponibilizou dados públicos para este canal. Confirme o link ou tente a aba Cadastro manual.", {"url": page_url})
157
+ owner_urls = metadata.get("ownerUrls", []) if isinstance(metadata, dict) else []
158
+ canonical_url = owner_urls[0] if owner_urls else page_url.removesuffix("/about")
159
+ canonical_url = canonical_url.replace("http://", "https://")
160
+ handle_match = re.search(r"/@([^/?]+)", canonical_url)
161
+ handle = f"@{handle_match.group(1)}" if handle_match else ""
162
+ title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
163
+ if not title and isinstance(header, dict):
164
+ title = _text_from_node(header.get("title"))
165
+ if not title:
166
+ title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
167
+ description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
168
+ youtube_id = str(metadata.get("externalId", "")) if isinstance(metadata, dict) else ""
169
+ if not youtube_id:
170
+ id_match = re.search(r"/channel/(UC[A-Za-z0-9_-]+)", canonical_url)
171
+ youtube_id = id_match.group(1) if id_match else ""
172
+ subscriber_value = _find_first_key(initial_data or {}, "subscriberCountText")
173
+ video_value = _find_first_key(initial_data or {}, "videoCountText")
174
+ data = {
175
+ "youtube_id": youtube_id,
176
+ "name": title,
177
+ "handle": handle,
178
+ "url": canonical_url,
179
+ "description": description,
180
+ "thumbnail_url": _thumbnail_from_metadata(metadata),
181
+ "subscriber_count": _parse_public_count(subscriber_value),
182
+ "video_count": _parse_public_count(video_value),
183
+ "view_count": None,
184
+ "metrics_source": "youtube_public_page",
185
+ "public_lookup": True,
186
+ }
187
+ return IntegrationResult(True, "Canal encontrado publicamente no YouTube, sem API Key. Reveja os dados antes de guardar.", data)
188
+ except requests.RequestException as exc:
189
+ return IntegrationResult(False, f"Não foi possível consultar a página pública do YouTube: {exc}", {"url": source})
190
+ except (json.JSONDecodeError, TypeError, ValueError) as exc:
191
+ return IntegrationResult(False, f"A página pública do YouTube mudou ou não pôde ser interpretada: {exc}", {"url": source})
192
+
29
193
  def fetch_channel(self, value: str) -> IntegrationResult:
30
194
  ref = self.extract_channel_ref(value)
31
195
  if not self.api_key:
@@ -59,6 +223,36 @@ class YouTubeAdapter:
59
223
  except requests.RequestException as exc:
60
224
  return IntegrationResult(False, f"Falha ao consultar o YouTube: {exc}", {})
61
225
 
226
+ def upload_video(self, video_path: str, **kwargs: Any) -> IntegrationResult:
227
+ """Publish through the adapted youtube-automation-agent, then OAuth fallback."""
228
+ from integrations.youtube_upload import upload_youtube_with_fallback
229
+ from hermes_ui.storage import STORAGE
230
+
231
+ return upload_youtube_with_fallback(
232
+ self.settings,
233
+ STORAGE,
234
+ video_path=video_path,
235
+ **kwargs,
236
+ )
237
+
238
+ def upload_status(self) -> dict[str, IntegrationResult]:
239
+ from integrations.youtube_upload import youtube_upload_status
240
+ from hermes_ui.storage import STORAGE
241
+
242
+ return youtube_upload_status(self.settings, STORAGE)
243
+
244
+ def authorize_agent(self) -> IntegrationResult:
245
+ from integrations.youtube_upload import authorize_youtube_agent
246
+ from hermes_ui.storage import STORAGE
247
+
248
+ return authorize_youtube_agent(self.settings, STORAGE)
249
+
250
+ def authorize_fallback(self) -> IntegrationResult:
251
+ from integrations.youtube_upload import authorize_youtube_fallback
252
+ from hermes_ui.storage import STORAGE
253
+
254
+ return authorize_youtube_fallback(self.settings, STORAGE)
255
+
62
256
 
63
257
  class TikTokAdapter:
64
258
  def __init__(self, settings: dict[str, Any]):
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from integrations.platforms import IntegrationResult
11
+
12
+
13
+ # Mantemos os mesmos escopos usados pelo youtube-automation-agent.
14
+ AGENT_SCOPES = [
15
+ "https://www.googleapis.com/auth/youtube.upload",
16
+ "https://www.googleapis.com/auth/youtube",
17
+ "https://www.googleapis.com/auth/youtube.readonly",
18
+ "https://www.googleapis.com/auth/yt-analytics.readonly",
19
+ ]
20
+ FALLBACK_SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
21
+
22
+
23
+ @dataclass
24
+ class UploadAttempt:
25
+ mechanism: str
26
+ result: IntegrationResult
27
+
28
+
29
+ def _safe_text(value: Any, default: str = "") -> str:
30
+ return str(value if value is not None else default).strip()
31
+
32
+
33
+ def _path_value(value: Any) -> Path | None:
34
+ if isinstance(value, dict):
35
+ value = value.get("path") or value.get("file") or value.get("filepath")
36
+ if not value:
37
+ return None
38
+ return Path(str(value)).expanduser()
39
+
40
+
41
+ def validate_video_file(video_path: str | os.PathLike[str]) -> tuple[bool, str, Path]:
42
+ """Validate the same real-video conditions used by the agent before publishing."""
43
+ path = Path(video_path).expanduser()
44
+ if not path.exists() or not path.is_file():
45
+ return False, "Ficheiro de vídeo não encontrado; o upload foi recusado.", path
46
+ if path.suffix.lower() != ".mp4":
47
+ return False, "O youtube-automation-agent só publica ficheiros MP4 reais.", path
48
+ if path.stat().st_size <= 0:
49
+ return False, "O ficheiro de vídeo está vazio; o upload foi recusado.", path
50
+ return True, "Vídeo válido.", path
51
+
52
+
53
+ def build_agent_video_metadata(
54
+ *,
55
+ title: str,
56
+ description: str = "",
57
+ tags: list[str] | None = None,
58
+ category_id: str = "22",
59
+ language: str = "pt-BR",
60
+ privacy_status: str = "private",
61
+ publish_at: str | None = None,
62
+ ) -> dict[str, Any]:
63
+ """Build the snippet/status payload used by PublishingSchedulingAgent."""
64
+ status: dict[str, Any] = {
65
+ "privacyStatus": privacy_status or "private",
66
+ "selfDeclaredMadeForKids": False,
67
+ }
68
+ if publish_at and status["privacyStatus"] == "private":
69
+ # YouTube requires a future publishAt with privacyStatus=private.
70
+ status["publishAt"] = publish_at
71
+ return {
72
+ "snippet": {
73
+ "title": _safe_text(title)[:100] or "Vídeo Thunderbolt",
74
+ "description": _safe_text(description),
75
+ "tags": [str(tag).strip() for tag in (tags or []) if str(tag).strip()],
76
+ "categoryId": _safe_text(category_id, "22"),
77
+ "defaultLanguage": _safe_text(language, "pt-BR"),
78
+ "defaultAudioLanguage": _safe_text(language, "pt-BR"),
79
+ },
80
+ "status": status,
81
+ }
82
+
83
+
84
+ class _GoogleYouTubeBase:
85
+ def __init__(self, settings: dict[str, Any], token_path: Path, scopes: list[str], alternate_token_path: Path | None = None):
86
+ self.settings = settings
87
+ self.client_id = _safe_text(settings.get("youtube_client_id")) or os.getenv("YOUTUBE_CLIENT_ID", "")
88
+ self.client_secret = _safe_text(settings.get("youtube_client_secret")) or os.getenv("YOUTUBE_CLIENT_SECRET", "")
89
+ self.token_path = token_path
90
+ self.alternate_token_path = alternate_token_path
91
+ self.scopes = scopes
92
+
93
+ @property
94
+ def configured(self) -> bool:
95
+ return bool(self.client_id and self.client_secret)
96
+
97
+ @property
98
+ def token_exists(self) -> bool:
99
+ return any(path.exists() and path.stat().st_size > 0 for path in self._token_candidates())
100
+
101
+ def _token_candidates(self) -> list[Path]:
102
+ paths = [self.token_path]
103
+ if self.alternate_token_path and self.alternate_token_path not in paths:
104
+ paths.append(self.alternate_token_path)
105
+ return paths
106
+
107
+ def _client_config(self) -> dict[str, Any]:
108
+ return {
109
+ "installed": {
110
+ "client_id": self.client_id,
111
+ "client_secret": self.client_secret,
112
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
113
+ "token_uri": "https://oauth2.googleapis.com/token",
114
+ "redirect_uris": ["http://localhost"],
115
+ }
116
+ }
117
+
118
+ def _load_credentials(self) -> Any:
119
+ try:
120
+ from google.auth.transport.requests import Request
121
+ from google.oauth2.credentials import Credentials
122
+ except ImportError as exc:
123
+ raise RuntimeError("As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.") from exc
124
+ token_source = next((path for path in self._token_candidates() if path.exists() and path.stat().st_size > 0), None)
125
+ if token_source is None:
126
+ return None
127
+ raw = json.loads(token_source.read_text(encoding="utf-8"))
128
+ if isinstance(raw, dict) and isinstance(raw.get("youtube"), dict):
129
+ raw = raw["youtube"]
130
+ credentials = Credentials.from_authorized_user_info(raw, self.scopes)
131
+ if credentials.expired and credentials.refresh_token:
132
+ credentials.refresh(Request())
133
+ self._save_credentials(credentials)
134
+ return credentials
135
+
136
+ def _save_credentials(self, credentials: Any, *, nested: bool = False) -> None:
137
+ self.token_path.parent.mkdir(parents=True, exist_ok=True)
138
+ payload = json.loads(credentials.to_json())
139
+ if nested:
140
+ payload = {"youtube": payload}
141
+ temporary = self.token_path.with_name(f".{self.token_path.name}.{secrets.token_hex(6)}.tmp")
142
+ temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
143
+ try:
144
+ os.chmod(temporary, 0o600)
145
+ except OSError:
146
+ pass
147
+ temporary.replace(self.token_path)
148
+ try:
149
+ os.chmod(self.token_path, 0o600)
150
+ except OSError:
151
+ pass
152
+
153
+ def _build_client(self, credentials: Any) -> Any:
154
+ try:
155
+ from googleapiclient.discovery import build
156
+ except ImportError as exc:
157
+ raise RuntimeError("A biblioteca Google API Client ainda não está instalada. Execute a instalação do Thunderbolt novamente.") from exc
158
+ return build("youtube", "v3", credentials=credentials, cache_discovery=False)
159
+
160
+ def authorize(self, *, open_browser: bool = True) -> IntegrationResult:
161
+ if not self.configured:
162
+ return IntegrationResult(False, "Preencha o YouTube OAuth Client ID e Client Secret nas Configurações.", {"status": "not_configured"})
163
+ try:
164
+ from google_auth_oauthlib.flow import InstalledAppFlow
165
+ except ImportError as exc:
166
+ return IntegrationResult(False, "As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.", {"status": "missing_dependencies", "error": str(exc)})
167
+ try:
168
+ flow = InstalledAppFlow.from_client_config(self._client_config(), self.scopes)
169
+ credentials = flow.run_local_server(
170
+ host="localhost",
171
+ port=0,
172
+ open_browser=open_browser,
173
+ access_type="offline",
174
+ prompt="consent",
175
+ include_granted_scopes="true",
176
+ )
177
+ self._save_credentials(credentials, nested=self.__class__.__name__ == "YouTubeAutomationAgentUploader")
178
+ return IntegrationResult(True, "Conta YouTube autorizada com sucesso.", {"status": "authorized", "token_path": str(self.token_path)})
179
+ except Exception as exc:
180
+ return IntegrationResult(False, f"A autorização Google falhou: {exc}", {"status": "authorization_failed"})
181
+
182
+ def status(self) -> IntegrationResult:
183
+ if not self.configured:
184
+ return IntegrationResult(False, "YouTube OAuth ainda não configurado.", {"status": "not_configured", "authorized": False})
185
+ if not self.token_exists:
186
+ return IntegrationResult(False, "YouTube OAuth configurado, mas a conta ainda não foi autorizada.", {"status": "requires_authorization", "authorized": False})
187
+ try:
188
+ credentials = self._load_credentials()
189
+ if credentials and credentials.valid:
190
+ return IntegrationResult(True, "YouTube autorizado e pronto para publicar.", {"status": "ready", "authorized": True})
191
+ if credentials and credentials.refresh_token:
192
+ return IntegrationResult(True, "YouTube autorizado; o token será renovado no próximo uso.", {"status": "ready_refreshable", "authorized": True})
193
+ except Exception as exc:
194
+ return IntegrationResult(False, f"Token YouTube inválido ou expirado: {exc}", {"status": "invalid_token", "authorized": False})
195
+ return IntegrationResult(False, "Token YouTube não está pronto para publicar.", {"status": "requires_authorization", "authorized": False})
196
+
197
+ def _upload_common(
198
+ self,
199
+ *,
200
+ video_path: str,
201
+ title: str,
202
+ description: str,
203
+ tags: list[str],
204
+ category_id: str,
205
+ language: str,
206
+ privacy_status: str,
207
+ publish_at: str | None,
208
+ thumbnail_path: str | None,
209
+ captions_path: str | None,
210
+ ) -> IntegrationResult:
211
+ valid, message, path = validate_video_file(video_path)
212
+ if not valid:
213
+ return IntegrationResult(False, message, {"status": "invalid_video", "path": str(path)})
214
+ try:
215
+ credentials = self._load_credentials()
216
+ if credentials is None:
217
+ return IntegrationResult(False, "Autorize primeiro a conta YouTube.", {"status": "requires_authorization", "path": str(path)})
218
+ youtube = self._build_client(credentials)
219
+ body = build_agent_video_metadata(
220
+ title=title,
221
+ description=description,
222
+ tags=tags,
223
+ category_id=category_id,
224
+ language=language,
225
+ privacy_status=privacy_status,
226
+ publish_at=publish_at,
227
+ )
228
+ try:
229
+ from googleapiclient.http import MediaFileUpload
230
+ except ImportError as exc:
231
+ raise RuntimeError("A biblioteca de upload Google ainda não está instalada.") from exc
232
+ media = MediaFileUpload(str(path), mimetype="video/mp4", chunksize=8 * 1024 * 1024, resumable=True)
233
+ request = youtube.videos().insert(part="snippet,status", body=body, media_body=media)
234
+ response = None
235
+ while response is None:
236
+ _, response = request.next_chunk()
237
+ video_id = response.get("id") if isinstance(response, dict) else None
238
+ if not video_id:
239
+ return IntegrationResult(False, "O YouTube não devolveu um ID de vídeo após o upload.", {"status": "upload_failed", "response": response or {}})
240
+
241
+ extras: dict[str, Any] = {}
242
+ thumbnail = _path_value(thumbnail_path)
243
+ if thumbnail and thumbnail.exists() and thumbnail.is_file():
244
+ try:
245
+ with thumbnail.open("rb") as handle:
246
+ youtube.thumbnails().set(videoId=video_id, media_body=handle).execute()
247
+ extras["thumbnail_uploaded"] = True
248
+ except Exception as exc:
249
+ extras["thumbnail_warning"] = str(exc)
250
+ captions = _path_value(captions_path)
251
+ if captions and captions.exists() and captions.is_file():
252
+ try:
253
+ with captions.open("rb") as handle:
254
+ youtube.captions().insert(
255
+ part="snippet",
256
+ body={"snippet": {"videoId": video_id, "language": language or "pt", "name": "Thunderbolt", "isDraft": False}},
257
+ media_body=handle,
258
+ ).execute()
259
+ extras["captions_uploaded"] = True
260
+ except Exception as exc:
261
+ extras["captions_warning"] = str(exc)
262
+ return IntegrationResult(
263
+ True,
264
+ f"Vídeo publicado no YouTube: https://www.youtube.com/watch?v={video_id}",
265
+ {"status": "published", "video_id": video_id, "url": f"https://www.youtube.com/watch?v={video_id}", "mechanism": self.__class__.__name__, **extras},
266
+ )
267
+ except Exception as exc:
268
+ return IntegrationResult(False, f"Falha no upload YouTube: {exc}", {"status": "upload_failed", "error": str(exc), "mechanism": self.__class__.__name__})
269
+
270
+
271
+ class YouTubeAutomationAgentUploader(_GoogleYouTubeBase):
272
+ """Adaptation of the source agent's PublishingSchedulingAgent inside Thunderbolt."""
273
+
274
+ def __init__(self, settings: dict[str, Any], storage_root: Path):
275
+ super().__init__(settings, storage_root / "state" / "youtube_agent_tokens.json", AGENT_SCOPES)
276
+
277
+ def upload(self, **kwargs: Any) -> IntegrationResult:
278
+ result = self._upload_common(**kwargs)
279
+ result.data.setdefault("mechanism", "youtube-automation-agent-adaptado")
280
+ return result
281
+
282
+
283
+ class DirectYouTubeOAuthUploader(_GoogleYouTubeBase):
284
+ """Independent minimal OAuth upload path used only after the primary path fails."""
285
+
286
+ def __init__(self, settings: dict[str, Any], storage_root: Path):
287
+ super().__init__(
288
+ settings,
289
+ storage_root / "state" / "youtube_oauth_fallback_token.json",
290
+ FALLBACK_SCOPES,
291
+ alternate_token_path=storage_root / "state" / "youtube_agent_tokens.json",
292
+ )
293
+
294
+ def upload(self, **kwargs: Any) -> IntegrationResult:
295
+ result = self._upload_common(**kwargs)
296
+ result.data.setdefault("mechanism", "oauth-direct-fallback")
297
+ return result
298
+
299
+
300
+ def upload_youtube_with_fallback(
301
+ settings: dict[str, Any],
302
+ storage_root: Path,
303
+ *,
304
+ video_path: str,
305
+ title: str,
306
+ description: str = "",
307
+ tags: list[str] | None = None,
308
+ category_id: str = "22",
309
+ language: str = "pt-BR",
310
+ privacy_status: str = "private",
311
+ publish_at: str | None = None,
312
+ thumbnail_path: str | None = None,
313
+ captions_path: str | None = None,
314
+ on_attempt: Callable[[UploadAttempt], None] | None = None,
315
+ ) -> IntegrationResult:
316
+ """Run the adapted agent path first and OAuth direct only as redundancy."""
317
+ kwargs = {
318
+ "video_path": video_path,
319
+ "title": title,
320
+ "description": description,
321
+ "tags": tags or [],
322
+ "category_id": category_id,
323
+ "language": language,
324
+ "privacy_status": privacy_status,
325
+ "publish_at": publish_at,
326
+ "thumbnail_path": thumbnail_path,
327
+ "captions_path": captions_path,
328
+ }
329
+ attempts: list[dict[str, Any]] = []
330
+ primary = YouTubeAutomationAgentUploader(settings, storage_root)
331
+ primary_result = primary.upload(**kwargs)
332
+ primary_result.data.setdefault("mechanism", "youtube-automation-agent-adaptado")
333
+ primary_attempt = UploadAttempt("youtube-automation-agent-adaptado", primary_result)
334
+ attempts.append({"mechanism": primary_attempt.mechanism, "ok": primary_result.ok, "message": primary_result.message, "data": primary_result.data})
335
+ if on_attempt:
336
+ on_attempt(primary_attempt)
337
+ if primary_result.ok:
338
+ primary_result.data["attempts"] = attempts
339
+ return primary_result
340
+
341
+ fallback = DirectYouTubeOAuthUploader(settings, storage_root)
342
+ fallback_result = fallback.upload(**kwargs)
343
+ fallback_result.data.setdefault("mechanism", "oauth-direct-fallback")
344
+ fallback_attempt = UploadAttempt("oauth-direct-fallback", fallback_result)
345
+ attempts.append({"mechanism": fallback_attempt.mechanism, "ok": fallback_result.ok, "message": fallback_result.message, "data": fallback_result.data})
346
+ if on_attempt:
347
+ on_attempt(fallback_attempt)
348
+ fallback_result.data["attempts"] = attempts
349
+ if fallback_result.ok:
350
+ fallback_result.message = f"Upload concluído pelo fallback OAuth directo após falha do agente: {fallback_result.message}"
351
+ return fallback_result
352
+ return IntegrationResult(False, f"O agente de upload falhou e o fallback OAuth também falhou. Agente: {primary_result.message} Fallback: {fallback_result.message}", {"status": "all_upload_mechanisms_failed", "attempts": attempts})
353
+
354
+
355
+ def authorize_youtube_agent(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
356
+ return YouTubeAutomationAgentUploader(settings, storage_root).authorize(open_browser=open_browser)
357
+
358
+
359
+ def authorize_youtube_fallback(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
360
+ return DirectYouTubeOAuthUploader(settings, storage_root).authorize(open_browser=open_browser)
361
+
362
+
363
+ def youtube_upload_status(settings: dict[str, Any], storage_root: Path) -> dict[str, IntegrationResult]:
364
+ return {
365
+ "agent": YouTubeAutomationAgentUploader(settings, storage_root).status(),
366
+ "fallback": DirectYouTubeOAuthUploader(settings, storage_root).status(),
367
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",
package/requirements.txt CHANGED
@@ -3,3 +3,6 @@ requests>=2.31,<3
3
3
  pandas>=2.0,<3
4
4
  imageio-ffmpeg>=0.6,<1
5
5
  toml>=0.10,<1
6
+ google-auth>=2.29,<3
7
+ google-auth-oauthlib>=1.2,<2
8
+ google-api-python-client>=2.120,<3
@@ -287,7 +287,7 @@ function writeSettings(moneyprinterPath) {
287
287
 
288
288
  function installThunderboltDependencies(python) {
289
289
  if (!existsSync(pythonBin)) run(python.command, [...python.args, "-m", "venv", venvPath]);
290
- installRequirementIfNeeded(join(root, "requirements.txt"), "thunderbolt_requirements_sha256", ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg"], "Thunderbolt");
290
+ installRequirementIfNeeded(join(root, "requirements.txt"), "thunderbolt_requirements_sha256", ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "google.auth", "google_auth_oauthlib", "googleapiclient"], "Thunderbolt");
291
291
  }
292
292
 
293
293
  function installMoneyPrinterDependencies(moneyprinterPath) {