@danhachuel/thunderbolt 0.2.19 → 0.2.21
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 +22 -6
- package/README.md +24 -7
- package/app/main.py +341 -19
- package/hermes_ui/domain.py +36 -0
- package/hermes_ui/music.py +82 -0
- package/hermes_ui/storage.py +14 -1
- package/hermes_ui/voice_preview.py +119 -0
- package/integrations/platforms.py +114 -44
- package/integrations/youtube_direct_upload.py +169 -0
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/scripts/cli.mjs +3 -1
- package/scripts/install.mjs +3 -1
package/app/main.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
8
|
+
import requests
|
|
7
9
|
import streamlit as st
|
|
8
10
|
|
|
9
11
|
ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -14,15 +16,35 @@ try:
|
|
|
14
16
|
except (OSError, json.JSONDecodeError):
|
|
15
17
|
APP_VERSION = ""
|
|
16
18
|
|
|
17
|
-
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, pipeline_summary, transition_task, update_channel
|
|
19
|
+
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, transition_task, update_channel
|
|
18
20
|
from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
19
21
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
20
22
|
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
23
|
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, read_packaged_skill, update_integration
|
|
24
|
+
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
25
|
+
from hermes_ui.voice_preview import DEFAULT_SAMPLE, synthesize_preview
|
|
22
26
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
27
|
+
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
23
28
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
24
29
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
25
30
|
|
|
31
|
+
AI_STYLE_OPTIONS = [
|
|
32
|
+
"Natural Realista",
|
|
33
|
+
"Cocomelon style",
|
|
34
|
+
"Retro 90s Cartoon",
|
|
35
|
+
"Wool sculpture miniatures",
|
|
36
|
+
"LEGO Style",
|
|
37
|
+
"Paper cutout style",
|
|
38
|
+
"Anime Style",
|
|
39
|
+
"Studio Ghibli Style",
|
|
40
|
+
"Stop Motion Style (Massinha)",
|
|
41
|
+
"Ukiyo Style",
|
|
42
|
+
"Pixel Animation",
|
|
43
|
+
"Pixar Style",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
WIDE_STYLE_OPTIONS = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
|
|
47
|
+
|
|
26
48
|
VIDEO_LANGUAGE_OPTIONS = [
|
|
27
49
|
"00 – Apenas Música de Fundo (Sem Falas)",
|
|
28
50
|
"01 – Inglês",
|
|
@@ -92,8 +114,10 @@ st.markdown("""
|
|
|
92
114
|
[data-testid="stSidebar"] .tb-brand-name { color:#f4f8fb; font-size:1.38rem; line-height:1.15; font-weight:750; letter-spacing:-0.02em; }
|
|
93
115
|
[data-testid="stSidebar"] .tb-brand-version { color:#8ba6bb; font-size:0.92rem; line-height:1; font-weight:500; }
|
|
94
116
|
[data-testid="stSidebar"] [data-testid="stButton"] { margin:0.025rem 0 !important; }
|
|
95
|
-
[data-testid="stSidebar"] [data-testid="stButton"] button { min-height:1.72rem; height:1.72rem; justify-content:flex-start; text-align:left; padding:0.10rem 0.52rem; border-radius:7px; border:1px solid transparent; font-size:0.86rem; font-weight:550; }
|
|
96
|
-
[data-testid="stSidebar"] [data-testid="stButton"]
|
|
117
|
+
[data-testid="stSidebar"] [data-testid="stButton"] button { min-height:1.72rem; height:1.72rem; justify-content:flex-start !important; text-align:left !important; padding:0.10rem 0.52rem; border-radius:7px; border:1px solid transparent; font-size:0.86rem; font-weight:550; }
|
|
118
|
+
[data-testid="stSidebar"] [data-testid="stButton"] button > div { width:100% !important; justify-content:flex-start !important; text-align:left !important; }
|
|
119
|
+
[data-testid="stSidebar"] [data-testid="stButton"] button [data-testid="stMarkdownContainer"] { flex:1 1 auto !important; width:100% !important; text-align:left !important; }
|
|
120
|
+
[data-testid="stSidebar"] [data-testid="stButton"] p { margin:0; line-height:1; width:100%; text-align:left !important; }
|
|
97
121
|
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] { background:transparent; color:#e7edf2; }
|
|
98
122
|
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"]:hover { background:#1c252e; border-color:#2d3944; color:#ffffff; }
|
|
99
123
|
[data-testid="stSidebar"] [data-testid="stBaseButton-primary"] { background:#292929; color:#ffffff; border-color:#3a3a3a; }
|
|
@@ -105,11 +129,11 @@ st.markdown("""
|
|
|
105
129
|
.content-value { color:#f4f8fb; font-size:1.8rem; font-weight:700; margin-top:.3rem; }
|
|
106
130
|
.stage { border-left:3px solid #35a7ff; padding:.65rem .8rem; margin:.4rem 0; background:#101d2a; border-radius:8px; }
|
|
107
131
|
.small-muted { color:#8ba6bb; font-size:.85rem; }
|
|
108
|
-
/* Identidade visual dos destinos
|
|
109
|
-
[data-testid="stMultiSelect"] [data-baseweb="tag"] {
|
|
110
|
-
[data-testid="stMultiSelect"] [data-baseweb="tag"] svg {
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
/* Identidade visual dos destinos: os tags BaseWeb são instáveis por ordem; usamos badges próprios por plataforma. */
|
|
133
|
+
[data-testid="stVerticalBlock"]:has(.tb-dest-marker) [data-testid="stMultiSelect"] [data-baseweb="tag"] { display:none !important; }
|
|
134
|
+
[data-testid="stVerticalBlock"]:has(.tb-dest-marker) [data-testid="stMultiSelect"] [data-baseweb="tag"] svg { display:none !important; }
|
|
135
|
+
.tb-destination-chips { display:flex; flex-wrap:wrap; gap:.28rem; margin:.25rem 0 .55rem; }
|
|
136
|
+
.tb-destination-chip { display:inline-block; color:#fff; padding:.22rem .65rem; border-radius:999px; font-weight:700; font-size:.82rem; line-height:1.1; }
|
|
113
137
|
</style>
|
|
114
138
|
""", unsafe_allow_html=True)
|
|
115
139
|
|
|
@@ -122,6 +146,37 @@ def channel_options() -> list[dict]:
|
|
|
122
146
|
return [c for c in read_json("channels.json", []) if c.get("active", True)]
|
|
123
147
|
|
|
124
148
|
|
|
149
|
+
def blueprint_catalog() -> list[tuple[str, str]]:
|
|
150
|
+
options = [("", "Sem Blueprint padrão")]
|
|
151
|
+
for path in list_blueprint_files():
|
|
152
|
+
try:
|
|
153
|
+
data = load_blueprint_file(path)
|
|
154
|
+
identifier = str(data.get("id") or path.stem)
|
|
155
|
+
label = str(data.get("name") or path.stem)
|
|
156
|
+
options.append((identifier, label))
|
|
157
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
158
|
+
continue
|
|
159
|
+
return options
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def valid_hhmm(value: str) -> bool:
|
|
163
|
+
return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def voice_catalog(current: str = "") -> list[str]:
|
|
167
|
+
voices = [""]
|
|
168
|
+
voice_file = ROOT / "integrations" / "data" / "azure_voices.json"
|
|
169
|
+
try:
|
|
170
|
+
voice_data = json.loads(voice_file.read_text(encoding="utf-8"))
|
|
171
|
+
voices.extend(f"{item.get('name', '')}-{item.get('gender', '')}" for item in voice_data if item.get("name"))
|
|
172
|
+
except (OSError, json.JSONDecodeError):
|
|
173
|
+
pass
|
|
174
|
+
for candidate in (current, "en-US-AriaNeural-Female", "pt-BR-FranciscaNeural-Female", "pt-BR-AntonioNeural-Male"):
|
|
175
|
+
if candidate and candidate not in voices:
|
|
176
|
+
voices.append(candidate)
|
|
177
|
+
return voices
|
|
178
|
+
|
|
179
|
+
|
|
125
180
|
def render_dashboard():
|
|
126
181
|
st.title("Thunderbolt")
|
|
127
182
|
st.caption("Interface local para operação e automação de conteúdo faceless")
|
|
@@ -279,6 +334,13 @@ def render_channels():
|
|
|
279
334
|
if st.session_state.get("yt_message"):
|
|
280
335
|
(st.success if st.session_state.get("yt_ok") else st.warning)(st.session_state["yt_message"])
|
|
281
336
|
imported = st.session_state.get("yt_import", {})
|
|
337
|
+
blueprint_items = blueprint_catalog()
|
|
338
|
+
blueprint_ids = [item[0] for item in blueprint_items]
|
|
339
|
+
blueprint_labels = {item[0]: item[1] for item in blueprint_items}
|
|
340
|
+
imported_blueprint = imported.get("default_blueprint_id") or imported.get("blueprint_id", "")
|
|
341
|
+
if imported_blueprint not in blueprint_ids:
|
|
342
|
+
blueprint_ids.append(imported_blueprint)
|
|
343
|
+
blueprint_labels[imported_blueprint] = imported_blueprint or "Sem Blueprint padrão"
|
|
282
344
|
if imported:
|
|
283
345
|
st.caption("Dados encontrados. Reveja ou edite os campos antes de guardar.")
|
|
284
346
|
with st.form("channel_import_form"):
|
|
@@ -286,8 +348,14 @@ def render_channels():
|
|
|
286
348
|
url = st.text_input("URL", value=imported.get("url", source if source.startswith("http") else ""), key="yt_import_url")
|
|
287
349
|
handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
|
|
288
350
|
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="yt_import_language")
|
|
289
|
-
style = st.selectbox("Estilo wide", ["
|
|
290
|
-
blueprint = st.
|
|
351
|
+
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="yt_import_style")
|
|
352
|
+
blueprint = st.selectbox("Blueprint padrão do canal", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
|
|
353
|
+
voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
|
|
354
|
+
current_voice = imported.get("default_voice") or imported.get("voice", "")
|
|
355
|
+
voice = st.selectbox("Voz padrão do canal", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
|
|
356
|
+
delegated_session_id = st.text_input("DELEGATED_SESSION_ID", value=imported.get("delegated_session_id", ""), type="password", key="yt_import_delegated_session_id")
|
|
357
|
+
automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
|
|
358
|
+
automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
|
|
291
359
|
description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
|
|
292
360
|
metrics = st.columns(3)
|
|
293
361
|
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
|
|
@@ -297,14 +365,22 @@ def render_channels():
|
|
|
297
365
|
if submitted:
|
|
298
366
|
if not name.strip():
|
|
299
367
|
st.error("Informe o nome do canal antes de guardar.")
|
|
368
|
+
elif not valid_hhmm(automation_time):
|
|
369
|
+
st.error("O horário diário deve estar no formato HH:MM, por exemplo 08:30.")
|
|
300
370
|
else:
|
|
301
371
|
metadata = {
|
|
302
372
|
**imported,
|
|
303
373
|
"handle": handle.strip(),
|
|
304
374
|
"description": description.strip(),
|
|
305
375
|
"language": language,
|
|
306
|
-
"style_wide": style,
|
|
376
|
+
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
307
377
|
"blueprint_id": blueprint.strip(),
|
|
378
|
+
"default_blueprint_id": blueprint.strip(),
|
|
379
|
+
"default_voice": voice.strip(),
|
|
380
|
+
"voice": voice.strip(),
|
|
381
|
+
"delegated_session_id": delegated_session_id.strip(),
|
|
382
|
+
"automation_on": bool(automation_on),
|
|
383
|
+
"automation_time": automation_time.strip() if valid_hhmm(automation_time) else "00:00",
|
|
308
384
|
"subscriber_count": int(subscriber_count) or None,
|
|
309
385
|
"video_count": int(video_count) or None,
|
|
310
386
|
"view_count": int(view_count) or None,
|
|
@@ -324,8 +400,16 @@ def render_channels():
|
|
|
324
400
|
handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
|
|
325
401
|
description = st.text_area("Descrição", key="manual_channel_description")
|
|
326
402
|
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
|
|
327
|
-
style = st.selectbox("Estilo wide", ["
|
|
328
|
-
|
|
403
|
+
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
|
|
404
|
+
manual_blueprint_items = blueprint_catalog()
|
|
405
|
+
manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
|
|
406
|
+
manual_blueprint_labels = {item[0]: item[1] for item in manual_blueprint_items}
|
|
407
|
+
blueprint = st.selectbox("Blueprint padrão do canal", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
|
|
408
|
+
voice_options = voice_catalog()
|
|
409
|
+
voice = st.selectbox("Voz padrão do canal", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
|
|
410
|
+
delegated_session_id = st.text_input("DELEGATED_SESSION_ID", type="password", key="manual_channel_delegated_session_id")
|
|
411
|
+
automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
|
|
412
|
+
automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="manual_channel_automation_time")
|
|
329
413
|
thumbnail_url = st.text_input("URL da imagem do canal", key="manual_channel_thumbnail")
|
|
330
414
|
metrics = st.columns(3)
|
|
331
415
|
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=0, key="manual_channel_subscribers")
|
|
@@ -335,13 +419,21 @@ def render_channels():
|
|
|
335
419
|
if submitted:
|
|
336
420
|
if not name.strip():
|
|
337
421
|
st.error("Informe o nome do canal.")
|
|
422
|
+
elif not valid_hhmm(automation_time):
|
|
423
|
+
st.error("O horário diário deve estar no formato HH:MM, por exemplo 08:30.")
|
|
338
424
|
else:
|
|
339
425
|
channel = create_channel(name, url, {
|
|
340
426
|
"handle": handle.strip(),
|
|
341
427
|
"description": description.strip(),
|
|
342
428
|
"language": language,
|
|
343
|
-
"style_wide": style,
|
|
429
|
+
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
344
430
|
"blueprint_id": blueprint.strip(),
|
|
431
|
+
"default_blueprint_id": blueprint.strip(),
|
|
432
|
+
"default_voice": voice.strip(),
|
|
433
|
+
"voice": voice.strip(),
|
|
434
|
+
"delegated_session_id": delegated_session_id.strip(),
|
|
435
|
+
"automation_on": bool(automation_on),
|
|
436
|
+
"automation_time": automation_time.strip(),
|
|
345
437
|
"thumbnail_url": thumbnail_url.strip(),
|
|
346
438
|
"subscriber_count": int(subscriber_count) or None,
|
|
347
439
|
"video_count": int(video_count) or None,
|
|
@@ -374,6 +466,26 @@ def render_channels():
|
|
|
374
466
|
active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel['id']}")
|
|
375
467
|
if active != channel.get("active"):
|
|
376
468
|
update_channel(channel["id"], {"active": active})
|
|
469
|
+
st.rerun()
|
|
470
|
+
delete_key = f"delete_pending_{channel['id']}"
|
|
471
|
+
if not st.session_state.get(delete_key, False):
|
|
472
|
+
if st.button("Apagar canal", key=f"delete_{channel['id']}", use_container_width=True):
|
|
473
|
+
st.session_state[delete_key] = True
|
|
474
|
+
st.rerun()
|
|
475
|
+
else:
|
|
476
|
+
st.warning("As tarefas, vídeos e artefactos relacionados serão preservados.")
|
|
477
|
+
confirm_col, cancel_col = st.columns(2)
|
|
478
|
+
with confirm_col:
|
|
479
|
+
if st.button("Confirmar apagar", key=f"confirm_delete_{channel['id']}", type="primary", use_container_width=True):
|
|
480
|
+
removed = delete_channel(channel["id"])
|
|
481
|
+
st.session_state.pop(delete_key, None)
|
|
482
|
+
if removed:
|
|
483
|
+
st.success(f"Canal {removed.get('name', 'seleccionado')} apagado.")
|
|
484
|
+
st.rerun()
|
|
485
|
+
with cancel_col:
|
|
486
|
+
if st.button("Cancelar", key=f"cancel_delete_{channel['id']}", use_container_width=True):
|
|
487
|
+
st.session_state.pop(delete_key, None)
|
|
488
|
+
st.rerun()
|
|
377
489
|
|
|
378
490
|
|
|
379
491
|
def render_new_video():
|
|
@@ -391,6 +503,47 @@ def render_new_video():
|
|
|
391
503
|
else:
|
|
392
504
|
selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"], key="new_video_channel")
|
|
393
505
|
selected = [selected_one["id"]]
|
|
506
|
+
wide_style_label = st.selectbox("Estilo wide", WIDE_STYLE_OPTIONS, key="new_video_style_wide")
|
|
507
|
+
style_ia = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key="new_video_style_ia") if wide_style_label == "full_ia" else ""
|
|
508
|
+
music_path = ""
|
|
509
|
+
music_source = ""
|
|
510
|
+
if wide_style_label == "Apenas Música":
|
|
511
|
+
st.caption("Apenas Música não gera Pexels/Pixabay nem fundo IA; o áudio musical será usado como elemento principal.")
|
|
512
|
+
music_source = st.radio("Fonte da música", ["Ficheiro existente", "Carregar ficheiro", "Criar via Suno API"], horizontal=True, key="new_video_music_source")
|
|
513
|
+
if music_source == "Ficheiro existente":
|
|
514
|
+
local_music = list_music_files()
|
|
515
|
+
if local_music:
|
|
516
|
+
selected_music = st.selectbox("Música local", local_music, format_func=lambda item: item.name, key="new_video_music_existing")
|
|
517
|
+
music_path = str(selected_music)
|
|
518
|
+
else:
|
|
519
|
+
st.warning("Ainda não existem músicas em storage/music. Escolha Carregar ficheiro ou Criar via Suno API.")
|
|
520
|
+
elif music_source == "Carregar ficheiro":
|
|
521
|
+
uploaded_music = st.file_uploader("Carregar música", type=["mp3", "wav", "m4a", "aac", "flac", "ogg"], key="new_video_music_upload")
|
|
522
|
+
if uploaded_music and st.button("Guardar música local", key="new_video_music_store", use_container_width=True):
|
|
523
|
+
try:
|
|
524
|
+
stored_music = store_music_file(uploaded_music.name, uploaded_music.getvalue())
|
|
525
|
+
st.session_state["new_video_music_path"] = str(stored_music)
|
|
526
|
+
st.success(f"Música guardada em `{stored_music}`")
|
|
527
|
+
except (OSError, ValueError) as exc:
|
|
528
|
+
st.error(str(exc))
|
|
529
|
+
music_path = st.session_state.get("new_video_music_path", "")
|
|
530
|
+
else:
|
|
531
|
+
suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="new_video_suno_prompt")
|
|
532
|
+
suno_title = st.text_input("Título da música", value=topic if "topic" in locals() else "Thunderbolt music", key="new_video_suno_title")
|
|
533
|
+
if st.button("Solicitar música no Suno", key="new_video_suno_request", use_container_width=True):
|
|
534
|
+
suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
|
|
535
|
+
(st.success if suno_result["ok"] else st.error)(suno_result["message"])
|
|
536
|
+
if suno_result["ok"]:
|
|
537
|
+
try:
|
|
538
|
+
generated = materialize_suno_audio(suno_result.get("data", {}), suno_title or "suno-generated.mp3")
|
|
539
|
+
if generated:
|
|
540
|
+
st.session_state["new_video_music_path"] = str(generated)
|
|
541
|
+
st.success(f"Música descarregada para `{generated}`")
|
|
542
|
+
else:
|
|
543
|
+
st.info("O pedido foi aceite, mas o endpoint ainda não devolveu uma URL de áudio. Consulte o estado no serviço Suno e adicione o ficheiro quando estiver pronto.")
|
|
544
|
+
except (OSError, requests.RequestException, ValueError) as exc:
|
|
545
|
+
st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
|
|
546
|
+
music_path = st.session_state.get("new_video_music_path", "")
|
|
394
547
|
with st.form("new_video_form"):
|
|
395
548
|
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
396
549
|
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
@@ -404,14 +557,17 @@ def render_new_video():
|
|
|
404
557
|
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
405
558
|
language = st.selectbox("Idioma", VIDEO_LANGUAGE_OPTIONS, key="video_language")
|
|
406
559
|
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
407
|
-
style = st.selectbox("Estilo wide", ["pexels", "full_ia"])
|
|
408
560
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
409
561
|
if submitted:
|
|
410
562
|
if not topic.strip() or not selected:
|
|
411
563
|
st.error("Informe um tópico e seleccione pelo menos um canal.")
|
|
412
564
|
else:
|
|
413
565
|
quantity = int(quantity if mode == "same_channel" else 1)
|
|
414
|
-
|
|
566
|
+
style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
|
|
567
|
+
if style == "music" and not music_path:
|
|
568
|
+
st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
|
|
569
|
+
st.stop()
|
|
570
|
+
batch = create_batch(mode, selected, topic, quantity, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source})
|
|
415
571
|
tasks = create_tasks_for_batch(batch)
|
|
416
572
|
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
|
|
417
573
|
with videos_tab:
|
|
@@ -447,12 +603,125 @@ def render_videos():
|
|
|
447
603
|
st.rerun()
|
|
448
604
|
|
|
449
605
|
|
|
606
|
+
def render_automation():
|
|
607
|
+
st.title("Automação")
|
|
608
|
+
st.caption("Agendamento diário da geração por canal. Esta versão configura e guarda a UI; não inicia gerações em segundo plano.")
|
|
609
|
+
st.info("A geração só será executada quando existir um worker/agendador ligado à pipeline. Activar Automação ON não inicia um processo automaticamente nesta etapa.")
|
|
610
|
+
channels = read_json("channels.json", [])
|
|
611
|
+
if not channels:
|
|
612
|
+
st.info("Nenhum canal cadastrado para configurar.")
|
|
613
|
+
for channel in channels:
|
|
614
|
+
channel_id = channel["id"]
|
|
615
|
+
with st.container(border=True):
|
|
616
|
+
cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
|
|
617
|
+
with cols[0]:
|
|
618
|
+
if channel.get("thumbnail_url"):
|
|
619
|
+
st.image(channel["thumbnail_url"], width=48)
|
|
620
|
+
else:
|
|
621
|
+
st.markdown("### YT")
|
|
622
|
+
with cols[1]:
|
|
623
|
+
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
624
|
+
st.caption(channel.get("handle") or channel.get("url") or "sem URL")
|
|
625
|
+
st.caption(f"Blueprint: {channel.get('default_blueprint_id') or channel.get('blueprint_id') or '—'} · Voz: {channel.get('default_voice') or channel.get('voice') or '—'}")
|
|
626
|
+
with cols[2]:
|
|
627
|
+
enabled = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
|
|
628
|
+
with cols[3]:
|
|
629
|
+
schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
|
|
630
|
+
with cols[4]:
|
|
631
|
+
if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
|
|
632
|
+
if not valid_hhmm(schedule_time):
|
|
633
|
+
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
634
|
+
else:
|
|
635
|
+
update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip()})
|
|
636
|
+
st.success("Agendamento guardado.")
|
|
637
|
+
st.rerun()
|
|
638
|
+
|
|
639
|
+
st.divider()
|
|
640
|
+
st.subheader("Vídeos cadastrados")
|
|
641
|
+
tasks = read_json("tasks.json", [])
|
|
642
|
+
if not tasks:
|
|
643
|
+
st.info("Ainda não existem vídeos cadastrados.")
|
|
644
|
+
for task in tasks:
|
|
645
|
+
with st.container(border=True):
|
|
646
|
+
task_cols = st.columns([2.6, 1.3, 1.3, 1.5])
|
|
647
|
+
with task_cols[0]:
|
|
648
|
+
st.write(f"**{task.get('topic', 'Sem tópico')}**")
|
|
649
|
+
st.caption(f"{task.get('channel_name', 'Canal')} · {task.get('id', '')}")
|
|
650
|
+
with task_cols[1]:
|
|
651
|
+
st.caption("Estado")
|
|
652
|
+
st.write(task.get("state", "—"))
|
|
653
|
+
with task_cols[2]:
|
|
654
|
+
st.caption("Estilo")
|
|
655
|
+
st.write(task.get("style_wide", "—"))
|
|
656
|
+
with task_cols[3]:
|
|
657
|
+
st.caption("Horário do canal")
|
|
658
|
+
st.write(task.get("automation_time", "00:00"))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def render_upload_direct():
|
|
662
|
+
st.subheader("Upload directo")
|
|
663
|
+
st.caption("Upload interno baseado no YouTube-Video-Upload-Frontend-Api, sem usar a quota oficial da Data API. Este método requer cookies e tokens fornecidos manualmente pelo utilizador.")
|
|
664
|
+
st.warning("Não cole cookies ou tokens em mensagens, issues ou Git. O Thunderbolt guarda-os apenas no storage local. A integração não extrai sessões automaticamente do navegador.")
|
|
665
|
+
settings = read_json("settings.json", {})
|
|
666
|
+
channels = read_json("channels.json", [])
|
|
667
|
+
tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
|
|
668
|
+
if not channels:
|
|
669
|
+
st.info("Cadastre um canal e configure o DELEGATED_SESSION_ID antes de usar o upload directo.")
|
|
670
|
+
if not tasks:
|
|
671
|
+
st.info("Não há vídeos prontos para upload directo.")
|
|
672
|
+
return
|
|
673
|
+
channel_map = {channel.get("id"): channel for channel in channels}
|
|
674
|
+
for task in tasks:
|
|
675
|
+
channel = channel_map.get(task.get("channel_id"), {})
|
|
676
|
+
video_path = (task.get("artifacts", {}) or {}).get("video", "")
|
|
677
|
+
with st.container(border=True):
|
|
678
|
+
st.write(f"**{task.get('topic', 'Sem tópico')}** — {task.get('channel_name', 'Canal')}")
|
|
679
|
+
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
680
|
+
if not channel.get("delegated_session_id"):
|
|
681
|
+
st.warning("Este canal não tem DELEGATED_SESSION_ID configurado na aba Canais.")
|
|
682
|
+
direct_cols = st.columns([2.2, 1, 1])
|
|
683
|
+
with direct_cols[0]:
|
|
684
|
+
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
685
|
+
with direct_cols[1]:
|
|
686
|
+
privacy = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"direct_privacy_{task['id']}")
|
|
687
|
+
with direct_cols[2]:
|
|
688
|
+
chunk_size = st.number_input("Chunk bytes", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)), key=f"direct_chunk_{task['id']}")
|
|
689
|
+
description = st.text_area("Descrição", value=task.get("description", ""), key=f"direct_description_{task['id']}", height=90)
|
|
690
|
+
if st.button("Enviar por Upload directo", type="primary", key=f"direct_upload_{task['id']}"):
|
|
691
|
+
result = YouTubeDirectUploader(settings, channel).upload(video_path, title=title, description=description, visibility=privacy, chunk_size=int(chunk_size))
|
|
692
|
+
record = {"task_id": task.get("id"), "channel_id": channel.get("id"), "destination": "YouTube direct frontend", "status": "published" if result.ok else "failed", "message": result.message, "data": result.data, "created_at": now()}
|
|
693
|
+
uploads = read_json("uploads.json", [])
|
|
694
|
+
uploads.append(record)
|
|
695
|
+
write_json("uploads.json", uploads)
|
|
696
|
+
(st.success if result.ok else st.error)(result.message)
|
|
697
|
+
|
|
698
|
+
|
|
450
699
|
def render_upload():
|
|
700
|
+
st.title("Upload")
|
|
701
|
+
upload_tab, direct_tab = st.tabs(["Upload convencional", "Upload directo"])
|
|
702
|
+
with direct_tab:
|
|
703
|
+
render_upload_direct()
|
|
704
|
+
with upload_tab:
|
|
705
|
+
render_upload_conventional()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def render_upload_conventional():
|
|
451
709
|
st.title("Upload")
|
|
452
710
|
settings = read_json("settings.json", {})
|
|
453
711
|
youtube = YouTubeAdapter(settings=settings)
|
|
454
712
|
tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
|
|
455
|
-
|
|
713
|
+
chip_colors = {"YouTube": "#ff4b4b", "TikTok": "#000000", "Instagram": "#e1306c", "Facebook Pages": "#1877f2"}
|
|
714
|
+
with st.container():
|
|
715
|
+
st.markdown('<div class="tb-dest-marker"></div>', unsafe_allow_html=True)
|
|
716
|
+
destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations")
|
|
717
|
+
if destination:
|
|
718
|
+
chips = "".join(f'<span class="tb-destination-chip" style="background:{chip_colors[item]}">{item}</span>' for item in destination)
|
|
719
|
+
st.markdown(f'<div class="tb-destination-chips">{chips}</div>', unsafe_allow_html=True)
|
|
720
|
+
|
|
721
|
+
if "Instagram" in destination:
|
|
722
|
+
st.info("Instagram está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
|
|
723
|
+
if "Facebook Pages" in destination:
|
|
724
|
+
st.info("Facebook Pages está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
|
|
456
725
|
|
|
457
726
|
if "YouTube" in destination:
|
|
458
727
|
st.markdown("**YouTube — youtube-automation-agent (principal)**")
|
|
@@ -529,6 +798,10 @@ def render_upload():
|
|
|
529
798
|
if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}"):
|
|
530
799
|
result = TikTokAdapter(settings).upload_video(video_path, task.get("topic", ""))
|
|
531
800
|
(st.success if result.ok else st.warning)(result.message)
|
|
801
|
+
if "Instagram" in destination:
|
|
802
|
+
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|
|
803
|
+
if "Facebook Pages" in destination:
|
|
804
|
+
st.button("Preparar Facebook Pages", key=f"upload_facebook_{task['id']}", disabled=True, help="UI preparada; publicação Facebook Pages ainda não está activa.")
|
|
532
805
|
|
|
533
806
|
|
|
534
807
|
def render_settings():
|
|
@@ -559,6 +832,21 @@ def render_settings():
|
|
|
559
832
|
with youtube_cols[2]:
|
|
560
833
|
youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0.")
|
|
561
834
|
|
|
835
|
+
with st.expander("Upload directo — sessão YouTube Frontend API"):
|
|
836
|
+
st.caption("Campos usados apenas pelo método Upload directo. Não são necessários para o youtube-automation-agent nem para a Data API pública.")
|
|
837
|
+
direct_cookie_cols = st.columns(3)
|
|
838
|
+
with direct_cookie_cols[0]:
|
|
839
|
+
direct_cookie_sid = text_setting("SID", "direct_cookie_sid", secret=True)
|
|
840
|
+
direct_cookie_ssid = text_setting("SSID", "direct_cookie_ssid", secret=True)
|
|
841
|
+
with direct_cookie_cols[1]:
|
|
842
|
+
direct_cookie_hsid = text_setting("HSID", "direct_cookie_hsid", secret=True)
|
|
843
|
+
direct_cookie_apisid = text_setting("APISID", "direct_cookie_apisid", secret=True)
|
|
844
|
+
with direct_cookie_cols[2]:
|
|
845
|
+
direct_cookie_sapisid = text_setting("SAPISID", "direct_cookie_sapisid", secret=True)
|
|
846
|
+
direct_session_info = text_setting("sessionInfo token", "direct_session_info", secret=True)
|
|
847
|
+
direct_innertube_api_key = text_setting("INNERTUBE_API_KEY", "direct_innertube_api_key", secret=True)
|
|
848
|
+
direct_chunk_size = st.number_input("Chunk size (múltiplo de 262144)", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)))
|
|
849
|
+
|
|
562
850
|
with st.expander("Serviço, materiais e rede"):
|
|
563
851
|
cols = st.columns(2)
|
|
564
852
|
with cols[0]:
|
|
@@ -605,7 +893,7 @@ def render_settings():
|
|
|
605
893
|
with cols[2]:
|
|
606
894
|
settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
|
|
607
895
|
|
|
608
|
-
with st.expander("Voz, TTS e música — Azure Speech
|
|
896
|
+
with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
|
|
609
897
|
cols = st.columns(2)
|
|
610
898
|
with cols[0]:
|
|
611
899
|
azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
|
|
@@ -623,6 +911,10 @@ def render_settings():
|
|
|
623
911
|
chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
|
|
624
912
|
sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
|
|
625
913
|
sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
|
|
914
|
+
st.markdown("**Suno — agente musical opcional**")
|
|
915
|
+
suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
|
|
916
|
+
suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
|
|
917
|
+
suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
|
|
626
918
|
|
|
627
919
|
with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
|
|
628
920
|
cols = st.columns(2)
|
|
@@ -656,6 +948,7 @@ def render_settings():
|
|
|
656
948
|
settings.update({
|
|
657
949
|
"port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
|
|
658
950
|
"youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
|
|
951
|
+
"direct_cookie_sid": direct_cookie_sid, "direct_cookie_ssid": direct_cookie_ssid, "direct_cookie_hsid": direct_cookie_hsid, "direct_cookie_apisid": direct_cookie_apisid, "direct_cookie_sapisid": direct_cookie_sapisid, "direct_session_info": direct_session_info, "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
|
|
659
952
|
"log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
|
|
660
953
|
"endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
|
|
661
954
|
"llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
|
|
@@ -665,7 +958,7 @@ def render_settings():
|
|
|
665
958
|
"elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
|
|
666
959
|
"pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
|
|
667
960
|
"chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
|
|
668
|
-
"sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "subtitle_provider": subtitle_provider,
|
|
961
|
+
"sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint, "subtitle_provider": subtitle_provider,
|
|
669
962
|
"ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
|
|
670
963
|
"whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
|
|
671
964
|
"tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
|
|
@@ -683,6 +976,33 @@ def render_settings():
|
|
|
683
976
|
except Exception as exc:
|
|
684
977
|
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
685
978
|
|
|
979
|
+
st.divider()
|
|
980
|
+
st.subheader("Teste de vozes")
|
|
981
|
+
st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
|
|
982
|
+
preview_cols = st.columns([1.1, 2.2, 1.2])
|
|
983
|
+
with preview_cols[0]:
|
|
984
|
+
preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
|
|
985
|
+
with preview_cols[1]:
|
|
986
|
+
if preview_provider in {"edge", "azure_speech"}:
|
|
987
|
+
preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
|
|
988
|
+
preview_voice = st.selectbox("Voz", preview_voice_options, index=preview_voice_options.index(settings.get("voice_preview_voice", "en-US-AriaNeural-Female")) if settings.get("voice_preview_voice", "en-US-AriaNeural-Female") in preview_voice_options else 0, format_func=lambda item: item or "Escolha uma voz", key="voice_preview_voice")
|
|
989
|
+
else:
|
|
990
|
+
preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
|
|
991
|
+
with preview_cols[2]:
|
|
992
|
+
preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
|
|
993
|
+
preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
|
|
994
|
+
if st.button("Testar voz", type="primary", key="voice_preview_generate"):
|
|
995
|
+
try:
|
|
996
|
+
preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
|
|
997
|
+
st.session_state["voice_preview_path"] = str(preview_path)
|
|
998
|
+
st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
|
|
999
|
+
except Exception as exc:
|
|
1000
|
+
st.error(f"Não foi possível gerar o preview: {exc}")
|
|
1001
|
+
preview_path = Path(st.session_state.get("voice_preview_path", ""))
|
|
1002
|
+
if preview_path.exists():
|
|
1003
|
+
st.audio(preview_path.read_bytes(), format="audio/mpeg")
|
|
1004
|
+
st.download_button("Descarregar amostra", data=preview_path.read_bytes(), file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
|
|
1005
|
+
|
|
686
1006
|
|
|
687
1007
|
def render_mcp():
|
|
688
1008
|
st.title("MCP")
|
|
@@ -890,6 +1210,7 @@ def main():
|
|
|
890
1210
|
("Blueprints", ":material/library_books:", "Blueprints"),
|
|
891
1211
|
("Canais", ":material/ondemand_video:", "Canais"),
|
|
892
1212
|
("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
|
|
1213
|
+
("Automação", ":material/schedule:", "Automação"),
|
|
893
1214
|
("Upload", ":material/cloud_upload:", "Upload"),
|
|
894
1215
|
("MCP", ":material/hub:", "MCP"),
|
|
895
1216
|
("Limpador de metadado", ":material/edit_note:", "Limpador de metadado"),
|
|
@@ -912,6 +1233,7 @@ def main():
|
|
|
912
1233
|
"Blueprints": render_blueprints,
|
|
913
1234
|
"Canais": render_channels,
|
|
914
1235
|
"Novo vídeo": render_new_video,
|
|
1236
|
+
"Automação": render_automation,
|
|
915
1237
|
"Upload": render_upload,
|
|
916
1238
|
"MCP": render_mcp,
|
|
917
1239
|
"Limpador de metadado": render_metadata_cleaner,
|
package/hermes_ui/domain.py
CHANGED
|
@@ -34,8 +34,13 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
|
|
|
34
34
|
"last_youtube_sync": None,
|
|
35
35
|
"language": "Português",
|
|
36
36
|
"blueprint_id": "",
|
|
37
|
+
"default_blueprint_id": "",
|
|
37
38
|
"style_wide": "pexels",
|
|
38
39
|
"voice": "",
|
|
40
|
+
"default_voice": "",
|
|
41
|
+
"delegated_session_id": "",
|
|
42
|
+
"automation_on": False,
|
|
43
|
+
"automation_time": "00:00",
|
|
39
44
|
"active": True,
|
|
40
45
|
"daily_limit": 1,
|
|
41
46
|
"backlog_total": 0,
|
|
@@ -61,6 +66,17 @@ def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] |
|
|
|
61
66
|
return None
|
|
62
67
|
|
|
63
68
|
|
|
69
|
+
def delete_channel(channel_id: str) -> dict[str, Any] | None:
|
|
70
|
+
"""Remove apenas o cadastro do canal, preservando tarefas e artefactos."""
|
|
71
|
+
channels = read_json("channels.json", [])
|
|
72
|
+
remaining = [channel for channel in channels if channel.get("id") != channel_id]
|
|
73
|
+
if len(remaining) == len(channels):
|
|
74
|
+
return None
|
|
75
|
+
removed = next(channel for channel in channels if channel.get("id") == channel_id)
|
|
76
|
+
write_json("channels.json", remaining)
|
|
77
|
+
return removed
|
|
78
|
+
|
|
79
|
+
|
|
64
80
|
def create_batch(mode: str, channel_ids: list[str], topic: str, quantity: int, options: dict[str, Any]) -> dict[str, Any]:
|
|
65
81
|
batch = {
|
|
66
82
|
"id": make_id("batch"),
|
|
@@ -97,6 +113,15 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
97
113
|
"language": batch["options"].get("language", channel.get("language", "Português")),
|
|
98
114
|
"format": batch["options"].get("format", "wide"),
|
|
99
115
|
"style_wide": batch["options"].get("style_wide", channel.get("style_wide", "pexels")),
|
|
116
|
+
"style_ia": batch["options"].get("style_ia", ""),
|
|
117
|
+
"music_mode": batch["options"].get("music_mode", False),
|
|
118
|
+
"music_path": batch["options"].get("music_path", ""),
|
|
119
|
+
"music_source": batch["options"].get("music_source", ""),
|
|
120
|
+
"background_mode": batch["options"].get("background_mode", "stock"),
|
|
121
|
+
"blueprint_id": channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
|
|
122
|
+
"voice": channel.get("default_voice") or channel.get("voice", ""),
|
|
123
|
+
"automation_on": bool(channel.get("automation_on", False)),
|
|
124
|
+
"automation_time": channel.get("automation_time", "00:00"),
|
|
100
125
|
"stage": "script",
|
|
101
126
|
"state": "to_do",
|
|
102
127
|
"progress": 0,
|
|
@@ -115,6 +140,17 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
115
140
|
return created
|
|
116
141
|
|
|
117
142
|
|
|
143
|
+
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
144
|
+
tasks = read_json("tasks.json", [])
|
|
145
|
+
for task in tasks:
|
|
146
|
+
if task.get("id") == task_id:
|
|
147
|
+
task.update(updates)
|
|
148
|
+
task["updated_at"] = now()
|
|
149
|
+
write_json("tasks.json", tasks)
|
|
150
|
+
return task
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
118
154
|
def transition_task(task_id: str, state: str | None = None, stage: str | None = None, error: str | None = None) -> dict[str, Any] | None:
|
|
119
155
|
if state and state not in VALID_STATES:
|
|
120
156
|
raise ValueError(f"Estado inválido: {state}")
|