@danhachuel/thunderbolt 0.2.17 → 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.
- package/MANUAL-INSTALACAO.md +10 -6
- package/README.md +22 -10
- package/app/main.py +193 -57
- package/integrations/platforms.py +196 -2
- package/integrations/youtube_upload.py +367 -0
- package/package.json +1 -1
- package/requirements.txt +3 -0
- package/scripts/install.mjs +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.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.
|
|
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.
|
|
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.
|
|
@@ -348,6 +348,8 @@ Na primeira execução, abra **Configurações** e reveja:
|
|
|
348
348
|
| YouTube Data API Key | Permitir importar nome, handle e métricas públicas de canais |
|
|
349
349
|
| YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para operações autenticadas |
|
|
350
350
|
| YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é a Data API Key |
|
|
351
|
+
| YouTube upload principal | Lógica do `youtube-automation-agent` adaptada e executada dentro do Thunderbolt |
|
|
352
|
+
| OAuth directo de redundância | Caminho alternativo accionado automaticamente se o agente falhar |
|
|
351
353
|
| TikTok Client ID/Secret | Credenciais da aplicação; Redirect URI, scopes e autorização ficam no TikTok for Developers Playground |
|
|
352
354
|
|
|
353
355
|
As chaves devem ser inseridas apenas na configuração local. Não as coloque no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
|
|
@@ -359,10 +361,10 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
359
361
|
1. **Dashboard:** confirme que a UI abre e mostra o estado local.
|
|
360
362
|
2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
|
|
361
363
|
3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
|
|
362
|
-
4. **Canais:**
|
|
364
|
+
4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** ou a Data API opcional; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
|
|
363
365
|
5. **Novo vídeo:** teste primeiro o modo **Canal específico** e depois os modos de lote.
|
|
364
|
-
6. **Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar
|
|
365
|
-
7. **Upload:** confirme
|
|
366
|
+
6. **Novo vídeo > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba.
|
|
367
|
+
7. **Upload:** autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key é exclusivamente para consultas públicas.
|
|
366
368
|
8. **Limpador de metadado:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos da aba Novo vídeo não são aceites nesta área.
|
|
367
369
|
9. **Configurações:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
368
370
|
|
|
@@ -506,6 +508,8 @@ Nunca publique ou envie por chat:
|
|
|
506
508
|
|
|
507
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.
|
|
508
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
|
+
|
|
509
513
|
## 14. Referências
|
|
510
514
|
|
|
511
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 |
|
|
18
|
-
| Novo vídeo |
|
|
19
|
-
|
|
|
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.
|
|
107
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
108
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
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
|
-
|
|
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
|
|
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("
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
262
|
-
if mode == "general":
|
|
263
|
-
selected = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid))
|
|
264
|
-
else:
|
|
265
|
-
selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"])
|
|
266
|
-
selected = [selected_one["id"]]
|
|
267
|
-
with st.form("new_video_form"):
|
|
268
|
-
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
269
|
-
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
270
|
-
language = st.selectbox("Idioma", ["Português", "English", "Español"], key="video_language")
|
|
271
|
-
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
272
|
-
style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
|
|
273
|
-
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
274
|
-
if submitted:
|
|
275
|
-
if not topic.strip() or not selected:
|
|
276
|
-
st.error("Informe um tópico e seleccione pelo menos um canal.")
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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():
|
|
@@ -620,12 +755,14 @@ def main():
|
|
|
620
755
|
("Blueprints", ":material/library_books:", "Blueprints"),
|
|
621
756
|
("Canais", ":material/ondemand_video:", "Canais"),
|
|
622
757
|
("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
|
|
623
|
-
("Vídeos", ":material/video_library:", "Vídeos"),
|
|
624
758
|
("Upload", ":material/cloud_upload:", "Upload"),
|
|
625
759
|
("Limpador de metadado", ":material/edit_note:", "Limpador de metadado"),
|
|
626
760
|
("Configurações", ":material/settings:", "Configurações"),
|
|
627
761
|
]
|
|
628
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
|
|
629
766
|
with st.sidebar:
|
|
630
767
|
version_markup = f'<span class="tb-brand-version">{APP_VERSION}</span>' if APP_VERSION else ""
|
|
631
768
|
st.markdown(f'<div class="tb-brand"><span class="tb-brand-name">Thunderbolt</span>{version_markup}</div>', unsafe_allow_html=True)
|
|
@@ -639,7 +776,6 @@ def main():
|
|
|
639
776
|
"Blueprints": render_blueprints,
|
|
640
777
|
"Canais": render_channels,
|
|
641
778
|
"Novo vídeo": render_new_video,
|
|
642
|
-
"Vídeos": render_videos,
|
|
643
779
|
"Upload": render_upload,
|
|
644
780
|
"Limpador de metadado": render_metadata_cleaner,
|
|
645
781
|
"Configurações": render_settings,
|
|
@@ -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.
|
|
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
package/requirements.txt
CHANGED
package/scripts/install.mjs
CHANGED
|
@@ -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) {
|