@danhachuel/thunderbolt 0.3.35 → 0.3.37
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 +29 -6
- package/README.md +18 -5
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/app/main.py +383 -4
- package/hermes_ui/pipeline_worker.py +291 -21
- package/hermes_ui/storage.py +25 -0
- package/integrations/music_uploads.py +381 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
package/app/main.py
CHANGED
|
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import html
|
|
5
5
|
import json
|
|
6
|
+
import mimetypes
|
|
6
7
|
import re
|
|
7
8
|
from contextlib import nullcontext
|
|
8
|
-
from datetime import date, datetime
|
|
9
|
+
from datetime import date, datetime, timezone
|
|
9
10
|
import sys
|
|
10
11
|
import uuid
|
|
11
12
|
from pathlib import Path
|
|
@@ -25,6 +26,7 @@ except (OSError, json.JSONDecodeError):
|
|
|
25
26
|
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
|
|
26
27
|
from hermes_ui.drafts import list_drafts, save_draft
|
|
27
28
|
from hermes_ui.automation_worker import load_worker_status
|
|
29
|
+
from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
|
|
28
30
|
from hermes_ui.storage import BLUEPRINTS, DEFAULT_LLM_PROVIDER, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
|
|
29
31
|
from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
|
|
30
32
|
from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
|
|
@@ -64,6 +66,7 @@ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdap
|
|
|
64
66
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
65
67
|
from integrations.postiz import PostizAdapter
|
|
66
68
|
from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
|
|
69
|
+
from integrations.music_uploads import JewelMusicAdapter, PushtunesAdapter, YTMusicApiAdapter, MUSIC_UPLOAD_EXTENSIONS, PUSHTUNES_OPERATIONS, PUSHTUNES_SOURCES, PUSHTUNES_TARGETS, YT_MUSIC_UPLOAD_EXTENSIONS
|
|
67
70
|
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
68
71
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
69
72
|
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
@@ -2447,6 +2450,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2447
2450
|
tasks = create_tasks_for_batch(batch)
|
|
2448
2451
|
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra {ui_text('Backlog Vídeos', current_ui_language())} para acompanhar.")
|
|
2449
2452
|
|
|
2453
|
+
_render_pipeline_progress_panel()
|
|
2450
2454
|
if draft_tab is not None:
|
|
2451
2455
|
with draft_tab:
|
|
2452
2456
|
render_video_from_draft()
|
|
@@ -3313,10 +3317,106 @@ def render_python_editor():
|
|
|
3313
3317
|
st.error(str(exc))
|
|
3314
3318
|
|
|
3315
3319
|
|
|
3320
|
+
_PIPELINE_STAGE_LABELS = {
|
|
3321
|
+
"niche": "Tema",
|
|
3322
|
+
"blueprint": "Blueprint",
|
|
3323
|
+
"brand": "Branding",
|
|
3324
|
+
"topic": "Tema",
|
|
3325
|
+
"script": "Roteiro",
|
|
3326
|
+
"title": "Título",
|
|
3327
|
+
"keywords": "Keywords",
|
|
3328
|
+
"thumbnail_prompt": "Prompt da thumbnail",
|
|
3329
|
+
"thumbnail": "Thumbnail",
|
|
3330
|
+
"video": "Vídeo",
|
|
3331
|
+
"edit": "Edição",
|
|
3332
|
+
"upload": "Upload",
|
|
3333
|
+
"idle": "A aguardar",
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
|
|
3337
|
+
def _pipeline_progress_value(task: dict[str, Any]) -> int:
|
|
3338
|
+
try:
|
|
3339
|
+
return max(0, min(100, int(task.get("progress") or 0)))
|
|
3340
|
+
except (TypeError, ValueError):
|
|
3341
|
+
return 0
|
|
3342
|
+
|
|
3343
|
+
|
|
3344
|
+
def _pipeline_stage_label(task: dict[str, Any]) -> str:
|
|
3345
|
+
stage = str(task.get("stage") or "pipeline")
|
|
3346
|
+
return _PIPELINE_STAGE_LABELS.get(stage, stage.replace("_", " ").title())
|
|
3347
|
+
|
|
3348
|
+
|
|
3349
|
+
def _pipeline_time_age(value: Any) -> str:
|
|
3350
|
+
text = str(value or "").strip()
|
|
3351
|
+
if not text:
|
|
3352
|
+
return "sem actualização registada"
|
|
3353
|
+
try:
|
|
3354
|
+
parsed = datetime.fromisoformat(text)
|
|
3355
|
+
if parsed.tzinfo is None:
|
|
3356
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
3357
|
+
seconds = max(0, int((datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds()))
|
|
3358
|
+
except ValueError:
|
|
3359
|
+
return f"última actualização: {text}"
|
|
3360
|
+
if seconds < 60:
|
|
3361
|
+
return f"actualizado há {seconds}s"
|
|
3362
|
+
if seconds < 3600:
|
|
3363
|
+
return f"actualizado há {seconds // 60}min"
|
|
3364
|
+
return f"actualizado há {seconds // 3600}h"
|
|
3365
|
+
|
|
3366
|
+
|
|
3367
|
+
def _render_pipeline_worker_banner(worker_status: dict[str, Any], active_count: int) -> None:
|
|
3368
|
+
if worker_status.get("alive"):
|
|
3369
|
+
stage = _PIPELINE_STAGE_LABELS.get(str(worker_status.get("stage") or "idle"), str(worker_status.get("stage") or "idle"))
|
|
3370
|
+
progress = max(0, min(100, int(worker_status.get("progress") or 0)))
|
|
3371
|
+
st.success(f"Worker de vídeo activo · {active_count} tarefa(s) em execução · {stage} · {progress}%")
|
|
3372
|
+
else:
|
|
3373
|
+
st.warning("Worker de vídeo sem heartbeat recente. O launcher deve estar aberto para processar as tarefas.")
|
|
3374
|
+
heartbeat = worker_status.get("last_heartbeat_at") or worker_status.get("updated_at")
|
|
3375
|
+
if heartbeat:
|
|
3376
|
+
st.caption(f"{_pipeline_time_age(heartbeat)} · timeout de execução: {STALE_TASK_SECONDS // 60} minutos")
|
|
3377
|
+
if worker_status.get("last_error"):
|
|
3378
|
+
st.error(f"Último erro do worker: {worker_status['last_error']}")
|
|
3379
|
+
|
|
3380
|
+
|
|
3381
|
+
@st.fragment(run_every=5.0)
|
|
3382
|
+
def _render_pipeline_progress_live() -> None:
|
|
3383
|
+
"""Poll the persisted pipeline state only while video tasks are active."""
|
|
3384
|
+
worker_status = load_pipeline_worker_status()
|
|
3385
|
+
tasks = read_json("tasks.json", [])
|
|
3386
|
+
active = [task for task in tasks if isinstance(task, dict) and str(task.get("state") or "") == "doing"]
|
|
3387
|
+
if not worker_status.get("alive") and active:
|
|
3388
|
+
recovered = recover_stale_tasks()
|
|
3389
|
+
if recovered:
|
|
3390
|
+
tasks = read_json("tasks.json", [])
|
|
3391
|
+
active = [task for task in tasks if isinstance(task, dict) and str(task.get("state") or "") == "doing"]
|
|
3392
|
+
worker_status = load_pipeline_worker_status()
|
|
3393
|
+
if not active:
|
|
3394
|
+
# A fragment that was already polling must stop itself after the worker
|
|
3395
|
+
# reaches done/failed; otherwise Streamlit keeps refreshing an obsolete
|
|
3396
|
+
# fragment even though the page no longer renders an active task.
|
|
3397
|
+
st.rerun(scope="app")
|
|
3398
|
+
return
|
|
3399
|
+
_render_pipeline_worker_banner(worker_status, len(active))
|
|
3400
|
+
for task in active:
|
|
3401
|
+
progress = _pipeline_progress_value(task)
|
|
3402
|
+
label = str(task.get("title") or task.get("topic") or task.get("id") or "Vídeo")
|
|
3403
|
+
st.progress(progress, text=f"{label} · {_pipeline_stage_label(task)} · {progress}%")
|
|
3404
|
+
st.caption(f"{task.get('channel_name') or 'Canal'} · {_pipeline_time_age(task.get('updated_at'))}")
|
|
3405
|
+
if task.get("error"):
|
|
3406
|
+
st.error(str(task.get("error")))
|
|
3407
|
+
|
|
3408
|
+
|
|
3409
|
+
def _render_pipeline_progress_panel() -> None:
|
|
3410
|
+
tasks = read_json("tasks.json", [])
|
|
3411
|
+
if any(isinstance(task, dict) and str(task.get("state") or "") == "doing" for task in tasks):
|
|
3412
|
+
_render_pipeline_progress_live()
|
|
3413
|
+
|
|
3414
|
+
|
|
3316
3415
|
def render_videos():
|
|
3317
3416
|
st.subheader("Backlog Videos")
|
|
3318
3417
|
st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
|
|
3319
3418
|
st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
|
|
3419
|
+
_render_pipeline_progress_panel()
|
|
3320
3420
|
tasks = read_json("tasks.json", [])
|
|
3321
3421
|
if not tasks:
|
|
3322
3422
|
st.info("Nenhum vídeo criado.")
|
|
@@ -3339,8 +3439,16 @@ def render_videos():
|
|
|
3339
3439
|
prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
|
|
3340
3440
|
st.caption(f"Thumbnail: {status}{prompt_note}")
|
|
3341
3441
|
with cols[1]: st.write(task.get("format", "wide"))
|
|
3342
|
-
with cols[2]:
|
|
3343
|
-
|
|
3442
|
+
with cols[2]:
|
|
3443
|
+
st.write(_pipeline_stage_label(task))
|
|
3444
|
+
if str(task.get("state") or "") in {"to_do", "doing", "blocked"}:
|
|
3445
|
+
progress = _pipeline_progress_value(task)
|
|
3446
|
+
st.progress(progress, text=f"{progress}%")
|
|
3447
|
+
with cols[3]:
|
|
3448
|
+
st.write(task.get("state", "—"))
|
|
3449
|
+
if task.get("error"):
|
|
3450
|
+
st.caption(str(task.get("error"))[:240])
|
|
3451
|
+
|
|
3344
3452
|
with cols[4]:
|
|
3345
3453
|
state = str(task.get("state") or "")
|
|
3346
3454
|
start_col, stop_col = st.columns(2)
|
|
@@ -3707,6 +3815,277 @@ def render_upload_direct():
|
|
|
3707
3815
|
(st.success if result.ok else st.error)(result.message)
|
|
3708
3816
|
|
|
3709
3817
|
|
|
3818
|
+
def _persist_music_upload_settings(updates: dict[str, Any]) -> dict[str, Any]:
|
|
3819
|
+
settings = read_json("settings.json", {})
|
|
3820
|
+
settings.update(updates)
|
|
3821
|
+
write_json("settings.json", settings)
|
|
3822
|
+
return settings
|
|
3823
|
+
|
|
3824
|
+
|
|
3825
|
+
def _render_music_source(prefix: str, extensions: set[str]) -> str:
|
|
3826
|
+
source_mode = st.radio(
|
|
3827
|
+
"Origem da música",
|
|
3828
|
+
["Ficheiro existente", "Carregar ficheiro"],
|
|
3829
|
+
horizontal=True,
|
|
3830
|
+
key=f"{prefix}_music_source_mode",
|
|
3831
|
+
)
|
|
3832
|
+
selected_path = str(st.session_state.get(f"{prefix}_music_path") or "")
|
|
3833
|
+
if source_mode == "Ficheiro existente":
|
|
3834
|
+
existing = [path for path in list_music_files() if path.suffix.lower() in extensions]
|
|
3835
|
+
if existing:
|
|
3836
|
+
selected = st.selectbox(
|
|
3837
|
+
"Música local",
|
|
3838
|
+
existing,
|
|
3839
|
+
index=next((index for index, item in enumerate(existing) if str(item) == selected_path), 0),
|
|
3840
|
+
format_func=lambda item: item.name,
|
|
3841
|
+
key=f"{prefix}_music_existing",
|
|
3842
|
+
)
|
|
3843
|
+
selected_path = str(selected)
|
|
3844
|
+
st.session_state[f"{prefix}_music_path"] = selected_path
|
|
3845
|
+
else:
|
|
3846
|
+
st.info("Ainda não existem ficheiros compatíveis em storage/music. Escolha Carregar ficheiro.")
|
|
3847
|
+
selected_path = ""
|
|
3848
|
+
else:
|
|
3849
|
+
uploaded = st.file_uploader(
|
|
3850
|
+
"Carregar ficheiro de música",
|
|
3851
|
+
type=sorted(extension.lstrip(".") for extension in extensions),
|
|
3852
|
+
key=f"{prefix}_music_file_upload",
|
|
3853
|
+
)
|
|
3854
|
+
if uploaded is not None and st.button("Guardar música no storage local", key=f"{prefix}_music_store", use_container_width=True):
|
|
3855
|
+
try:
|
|
3856
|
+
stored = store_music_file(uploaded.name, uploaded.getvalue())
|
|
3857
|
+
selected_path = str(stored)
|
|
3858
|
+
st.session_state[f"{prefix}_music_path"] = selected_path
|
|
3859
|
+
st.success(f"Música guardada em `{stored}`.")
|
|
3860
|
+
except (OSError, ValueError) as exc:
|
|
3861
|
+
st.error(str(exc))
|
|
3862
|
+
selected_path = str(st.session_state.get(f"{prefix}_music_path") or selected_path)
|
|
3863
|
+
if selected_path and Path(selected_path).is_file():
|
|
3864
|
+
st.caption(f"Ficheiro seleccionado: `{selected_path}`")
|
|
3865
|
+
return selected_path
|
|
3866
|
+
return ""
|
|
3867
|
+
|
|
3868
|
+
|
|
3869
|
+
def _record_music_upload(destination: str, result: IntegrationResult, *, music_path: str = "", target: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
3870
|
+
record = {
|
|
3871
|
+
"id": uuid.uuid4().hex,
|
|
3872
|
+
"destination": destination,
|
|
3873
|
+
"music_path": music_path,
|
|
3874
|
+
"target": target or {},
|
|
3875
|
+
"status": "published" if result.ok else "failed",
|
|
3876
|
+
"message": result.message,
|
|
3877
|
+
"data": result.data,
|
|
3878
|
+
"created_at": now(),
|
|
3879
|
+
}
|
|
3880
|
+
uploads = read_json("uploads.json", [])
|
|
3881
|
+
uploads.append(record)
|
|
3882
|
+
write_json("uploads.json", uploads)
|
|
3883
|
+
reconcile_persisted_notifications()
|
|
3884
|
+
return record
|
|
3885
|
+
|
|
3886
|
+
|
|
3887
|
+
def _render_music_upload_history() -> None:
|
|
3888
|
+
records = [
|
|
3889
|
+
record
|
|
3890
|
+
for record in read_json("uploads.json", [])
|
|
3891
|
+
if isinstance(record, dict)
|
|
3892
|
+
and any(name in str(record.get("destination") or "").lower() for name in ("jewelmusic", "pushtunes", "youtube music", "ytmusicapi"))
|
|
3893
|
+
]
|
|
3894
|
+
st.divider()
|
|
3895
|
+
st.subheader("Histórico de uploads de música")
|
|
3896
|
+
if not records:
|
|
3897
|
+
st.caption("Ainda não existem uploads de música registados.")
|
|
3898
|
+
return
|
|
3899
|
+
for record in reversed(records[-20:]):
|
|
3900
|
+
status = "Concluído" if record.get("status") == "published" else "Falhou"
|
|
3901
|
+
path = Path(str(record.get("music_path") or ""))
|
|
3902
|
+
with st.container(border=True):
|
|
3903
|
+
st.write(f"**{record.get('destination', 'Upload musical')}** · {status}")
|
|
3904
|
+
st.caption(f"{record.get('created_at', '—')} · {path.name if path.name else record.get('target', {})}")
|
|
3905
|
+
st.write(record.get("message", ""))
|
|
3906
|
+
if path.is_file():
|
|
3907
|
+
st.download_button(
|
|
3908
|
+
"Descarregar cópia local",
|
|
3909
|
+
data=path.read_bytes(),
|
|
3910
|
+
file_name=path.name,
|
|
3911
|
+
mime=mimetypes.guess_type(path.name)[0] or "audio/mpeg",
|
|
3912
|
+
key=f"music_history_download_{record.get('id')}",
|
|
3913
|
+
)
|
|
3914
|
+
|
|
3915
|
+
|
|
3916
|
+
def _render_jewelmusic_upload_tab() -> None:
|
|
3917
|
+
st.subheader("JewelMusic")
|
|
3918
|
+
st.caption("Upload de tracks para o JewelMusic através do endpoint documentado do SDK, com distribuição posterior para as plataformas disponíveis na sua conta.")
|
|
3919
|
+
st.info("A API Key é guardada apenas no settings.json local. O teste consulta /v1/ping e não cria uma track.")
|
|
3920
|
+
settings = read_json("settings.json", {})
|
|
3921
|
+
with st.form("jewelmusic_settings_form"):
|
|
3922
|
+
enabled = st.checkbox("Activar JewelMusic", value=bool(settings.get("jewelmusic_enabled", False)))
|
|
3923
|
+
api_key = st.text_input("JewelMusic API Key", value=str(settings.get("jewelmusic_api_key") or ""), type="password")
|
|
3924
|
+
base_url = st.text_input("Base URL", value=str(settings.get("jewelmusic_base_url") or "https://api.jewelmusic.com"))
|
|
3925
|
+
proxy_url = st.text_input("Proxy opcional", value=str(settings.get("jewelmusic_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
|
|
3926
|
+
timeout_seconds = st.number_input("Timeout (segundos)", min_value=5, max_value=900, value=int(settings.get("jewelmusic_timeout_seconds", 120)), step=5)
|
|
3927
|
+
save = st.form_submit_button("Guardar configuração JewelMusic", type="primary", use_container_width=True)
|
|
3928
|
+
if save:
|
|
3929
|
+
settings = _persist_music_upload_settings({
|
|
3930
|
+
"jewelmusic_enabled": bool(enabled),
|
|
3931
|
+
"jewelmusic_api_key": api_key.strip(),
|
|
3932
|
+
"jewelmusic_base_url": base_url.strip().rstrip("/"),
|
|
3933
|
+
"jewelmusic_proxy_url": proxy_url.strip(),
|
|
3934
|
+
"jewelmusic_timeout_seconds": int(timeout_seconds),
|
|
3935
|
+
})
|
|
3936
|
+
st.success("Configuração JewelMusic guardada no storage local.")
|
|
3937
|
+
adapter = JewelMusicAdapter(settings)
|
|
3938
|
+
if st.button("Testar conexão JewelMusic", key="jewelmusic_test", use_container_width=True):
|
|
3939
|
+
test_result = adapter.test_connection()
|
|
3940
|
+
(st.success if test_result.ok else st.error)(test_result.message)
|
|
3941
|
+
music_path = _render_music_source("jewelmusic", MUSIC_UPLOAD_EXTENSIONS)
|
|
3942
|
+
metadata_cols = st.columns(4)
|
|
3943
|
+
with metadata_cols[0]:
|
|
3944
|
+
title = st.text_input("Título", value=Path(music_path).stem if music_path else "", key="jewelmusic_title")
|
|
3945
|
+
with metadata_cols[1]:
|
|
3946
|
+
artist = st.text_input("Artista", key="jewelmusic_artist")
|
|
3947
|
+
with metadata_cols[2]:
|
|
3948
|
+
album = st.text_input("Álbum", key="jewelmusic_album")
|
|
3949
|
+
with metadata_cols[3]:
|
|
3950
|
+
year = st.text_input("Ano", key="jewelmusic_year")
|
|
3951
|
+
genre = st.text_input("Género", key="jewelmusic_genre")
|
|
3952
|
+
if st.button("Enviar música para JewelMusic", type="primary", key="jewelmusic_upload", use_container_width=True, disabled=not bool(music_path)):
|
|
3953
|
+
result = JewelMusicAdapter(read_json("settings.json", {})).upload_track(music_path, title=title, artist=artist, album=album, year=year, genre=genre)
|
|
3954
|
+
_record_music_upload("JewelMusic", result, music_path=music_path, target={"artist": artist, "title": title})
|
|
3955
|
+
(st.success if result.ok else st.error)(result.message)
|
|
3956
|
+
|
|
3957
|
+
|
|
3958
|
+
def _store_pushtunes_csv(uploaded: Any) -> str:
|
|
3959
|
+
target = STORAGE / "music" / "pushtunes-source.csv"
|
|
3960
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3961
|
+
target.write_bytes(uploaded.getvalue())
|
|
3962
|
+
return str(target)
|
|
3963
|
+
|
|
3964
|
+
|
|
3965
|
+
def _render_pushtunes_upload_tab() -> None:
|
|
3966
|
+
st.subheader("Pushtunes")
|
|
3967
|
+
st.caption("Sincronização de biblioteca a partir de Subsonic, Jellyfin ou CSV para Spotify, YouTube Music ou Tidal. Pushtunes não transforma um MP3 isolado num upload; para isso use JewelMusic ou ytmusicapi.")
|
|
3968
|
+
st.warning("O Pushtunes usa as credenciais próprias dos serviços. O Thunderbolt apenas inicia o comando local com parâmetros separados e mascara os segredos na saída.")
|
|
3969
|
+
settings = read_json("settings.json", {})
|
|
3970
|
+
with st.form("pushtunes_settings_form"):
|
|
3971
|
+
enabled = st.checkbox("Activar Pushtunes", value=bool(settings.get("pushtunes_enabled", False)))
|
|
3972
|
+
executable = st.text_input("Executável ou módulo", value=str(settings.get("pushtunes_executable") or "pushtunes"), help="Por padrão é usado o comando pushtunes instalado no ambiente Python do Thunderbolt.")
|
|
3973
|
+
source = st.selectbox("Fonte", list(PUSHTUNES_SOURCES), index=list(PUSHTUNES_SOURCES).index(str(settings.get("pushtunes_source") or "csv")) if str(settings.get("pushtunes_source") or "csv") in PUSHTUNES_SOURCES else 0, format_func=lambda value: {"csv": "CSV local", "subsonic": "Subsonic/Navidrome", "jellyfin": "Jellyfin", "spotify": "Spotify", "ytm": "YouTube Music"}.get(value, value))
|
|
3974
|
+
target = st.selectbox("Destino", list(PUSHTUNES_TARGETS), index=list(PUSHTUNES_TARGETS).index(str(settings.get("pushtunes_target") or "ytm")) if str(settings.get("pushtunes_target") or "ytm") in PUSHTUNES_TARGETS else 0, format_func=lambda value: {"spotify": "Spotify", "ytm": "YouTube Music", "tidal": "Tidal", "csv": "CSV"}.get(value, value))
|
|
3975
|
+
operation = st.selectbox("Operação", list(PUSHTUNES_OPERATIONS), index=list(PUSHTUNES_OPERATIONS).index(str(settings.get("pushtunes_operation") or "tracks")) if str(settings.get("pushtunes_operation") or "tracks") in PUSHTUNES_OPERATIONS else 0, format_func=lambda value: {"tracks": "Tracks", "albums": "Álbuns", "playlist": "Playlist"}.get(value, value))
|
|
3976
|
+
profile = st.text_input("Perfil Pushtunes (.toml), opcional", value=str(settings.get("pushtunes_profile") or ""))
|
|
3977
|
+
csv_file = st.text_input("Caminho CSV", value=str(settings.get("pushtunes_csv_file") or ""))
|
|
3978
|
+
ytm_auth_file = st.text_input("Caminho browser.json do YouTube Music", value=str(settings.get("pushtunes_ytm_auth_file") or ""))
|
|
3979
|
+
tidal_session_file = st.text_input("Caminho tidal-session.json do Tidal", value=str(settings.get("pushtunes_tidal_session_file") or ""))
|
|
3980
|
+
playlist_name = st.text_input("Nome da playlist", value=str(settings.get("pushtunes_playlist_name") or ""))
|
|
3981
|
+
similarity = st.slider("Similaridade mínima", min_value=0.0, max_value=1.0, value=float(settings.get("pushtunes_similarity", 0.8)), step=0.05)
|
|
3982
|
+
working_directory = st.text_input("Directório de trabalho", value=str(settings.get("pushtunes_working_directory") or ""))
|
|
3983
|
+
spotify_client_id = st.text_input("Spotify Client ID", value=str(settings.get("pushtunes_spotify_client_id") or ""))
|
|
3984
|
+
spotify_client_secret = st.text_input("Spotify Client Secret", value=str(settings.get("pushtunes_spotify_client_secret") or ""), type="password")
|
|
3985
|
+
spotify_redirect_uri = st.text_input("Spotify Redirect URI", value=str(settings.get("pushtunes_spotify_redirect_uri") or ""))
|
|
3986
|
+
timeout_seconds = st.number_input("Timeout Pushtunes (segundos)", min_value=30, max_value=3600, value=int(settings.get("pushtunes_timeout_seconds", 1800)), step=30)
|
|
3987
|
+
save = st.form_submit_button("Guardar configuração Pushtunes", type="primary", use_container_width=True)
|
|
3988
|
+
if save:
|
|
3989
|
+
settings = _persist_music_upload_settings({
|
|
3990
|
+
"pushtunes_enabled": bool(enabled),
|
|
3991
|
+
"pushtunes_executable": executable.strip() or "pushtunes",
|
|
3992
|
+
"pushtunes_source": source,
|
|
3993
|
+
"pushtunes_target": target,
|
|
3994
|
+
"pushtunes_operation": operation,
|
|
3995
|
+
"pushtunes_profile": profile.strip(),
|
|
3996
|
+
"pushtunes_csv_file": csv_file.strip(),
|
|
3997
|
+
"pushtunes_ytm_auth_file": ytm_auth_file.strip(),
|
|
3998
|
+
"pushtunes_tidal_session_file": tidal_session_file.strip(),
|
|
3999
|
+
"pushtunes_playlist_name": playlist_name.strip(),
|
|
4000
|
+
"pushtunes_similarity": float(similarity),
|
|
4001
|
+
"pushtunes_working_directory": working_directory.strip(),
|
|
4002
|
+
"pushtunes_spotify_client_id": spotify_client_id.strip(),
|
|
4003
|
+
"pushtunes_spotify_client_secret": spotify_client_secret.strip(),
|
|
4004
|
+
"pushtunes_spotify_redirect_uri": spotify_redirect_uri.strip(),
|
|
4005
|
+
"pushtunes_timeout_seconds": int(timeout_seconds),
|
|
4006
|
+
})
|
|
4007
|
+
st.success("Configuração Pushtunes guardada no storage local.")
|
|
4008
|
+
if source == "csv":
|
|
4009
|
+
st.caption("Pode carregar o CSV nesta página e usar o caminho guardado como origem Pushtunes.")
|
|
4010
|
+
csv_upload = st.file_uploader("Carregar CSV de origem Pushtunes", type=["csv"], key="pushtunes_csv_upload")
|
|
4011
|
+
if csv_upload is not None and st.button("Guardar CSV Pushtunes no storage", key="pushtunes_csv_store", use_container_width=True):
|
|
4012
|
+
stored_csv = _store_pushtunes_csv(csv_upload)
|
|
4013
|
+
settings = _persist_music_upload_settings({"pushtunes_csv_file": stored_csv})
|
|
4014
|
+
st.success(f"CSV guardado em `{stored_csv}`.")
|
|
4015
|
+
adapter = PushtunesAdapter(read_json("settings.json", {}))
|
|
4016
|
+
status = adapter.status()
|
|
4017
|
+
(st.success if status.ok else st.warning)(status.message)
|
|
4018
|
+
if st.button("Validar instalação e configuração Pushtunes", key="pushtunes_test", use_container_width=True):
|
|
4019
|
+
check = PushtunesAdapter(read_json("settings.json", {})).status()
|
|
4020
|
+
(st.success if check.ok else st.error)(check.message)
|
|
4021
|
+
if st.button("Executar sincronização Pushtunes", type="primary", key="pushtunes_sync", use_container_width=True, disabled=not status.ok):
|
|
4022
|
+
result = PushtunesAdapter(read_json("settings.json", {})).sync()
|
|
4023
|
+
_record_music_upload(f"Pushtunes ({adapter.source} → {adapter.target})", result, target={"source": adapter.source, "target": adapter.target, "operation": adapter.operation})
|
|
4024
|
+
(st.success if result.ok else st.error)(result.message)
|
|
4025
|
+
if result.data.get("stdout") or result.data.get("stderr"):
|
|
4026
|
+
with st.expander("Saída técnica do Pushtunes"):
|
|
4027
|
+
st.text(result.data.get("stdout", ""))
|
|
4028
|
+
if result.data.get("stderr"):
|
|
4029
|
+
st.text(result.data["stderr"])
|
|
4030
|
+
|
|
4031
|
+
|
|
4032
|
+
def _store_ytmusicapi_auth(uploaded: Any) -> str:
|
|
4033
|
+
target = STORAGE / "ytmusicapi" / "browser.json"
|
|
4034
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
4035
|
+
target.write_bytes(uploaded.getvalue())
|
|
4036
|
+
return str(target)
|
|
4037
|
+
|
|
4038
|
+
|
|
4039
|
+
def _render_ytmusicapi_upload_tab() -> None:
|
|
4040
|
+
st.subheader("ytmusicapi")
|
|
4041
|
+
st.caption("Upload de músicas para YouTube Music através da API não oficial ytmusicapi. O serviço exige autenticação de browser e aceita MP3, M4A, WMA, FLAC ou OGG até 300 MB.")
|
|
4042
|
+
st.warning("Use um browser.json exportado/configurado pelo ytmusicapi. Não cole cookies ou tokens na conversa, no GitHub ou em issues; o ficheiro fica apenas no storage local.")
|
|
4043
|
+
settings = read_json("settings.json", {})
|
|
4044
|
+
with st.form("ytmusicapi_settings_form"):
|
|
4045
|
+
enabled = st.checkbox("Activar ytmusicapi", value=bool(settings.get("ytmusicapi_enabled", False)))
|
|
4046
|
+
auth_file = st.text_input("Caminho do browser.json", value=str(settings.get("ytmusicapi_auth_file") or ""))
|
|
4047
|
+
proxy_url = st.text_input("Proxy opcional", value=str(settings.get("ytmusicapi_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
|
|
4048
|
+
timeout_seconds = st.number_input("Timeout (segundos)", min_value=30, max_value=900, value=int(settings.get("ytmusicapi_timeout_seconds", 240)), step=10)
|
|
4049
|
+
save = st.form_submit_button("Guardar configuração ytmusicapi", type="primary", use_container_width=True)
|
|
4050
|
+
if save:
|
|
4051
|
+
settings = _persist_music_upload_settings({
|
|
4052
|
+
"ytmusicapi_enabled": bool(enabled),
|
|
4053
|
+
"ytmusicapi_auth_file": auth_file.strip(),
|
|
4054
|
+
"ytmusicapi_proxy_url": proxy_url.strip(),
|
|
4055
|
+
"ytmusicapi_timeout_seconds": int(timeout_seconds),
|
|
4056
|
+
})
|
|
4057
|
+
st.success("Configuração ytmusicapi guardada no storage local.")
|
|
4058
|
+
auth_upload = st.file_uploader("Carregar browser.json", type=["json"], key="ytmusicapi_auth_upload")
|
|
4059
|
+
if auth_upload is not None and st.button("Guardar browser.json no storage local", key="ytmusicapi_auth_store", use_container_width=True):
|
|
4060
|
+
stored_auth = _store_ytmusicapi_auth(auth_upload)
|
|
4061
|
+
settings = _persist_music_upload_settings({"ytmusicapi_auth_file": stored_auth, "ytmusicapi_enabled": True})
|
|
4062
|
+
st.success(f"Ficheiro de autenticação guardado em `{stored_auth}`.")
|
|
4063
|
+
adapter = YTMusicApiAdapter(read_json("settings.json", {}))
|
|
4064
|
+
status = adapter.status()
|
|
4065
|
+
(st.success if status.ok else st.warning)(status.message)
|
|
4066
|
+
if st.button("Testar autenticação ytmusicapi", key="ytmusicapi_test", use_container_width=True):
|
|
4067
|
+
result = YTMusicApiAdapter(read_json("settings.json", {})).test_connection()
|
|
4068
|
+
(st.success if result.ok else st.error)(result.message)
|
|
4069
|
+
music_path = _render_music_source("ytmusicapi", YT_MUSIC_UPLOAD_EXTENSIONS)
|
|
4070
|
+
if st.button("Enviar música para YouTube Music", type="primary", key="ytmusicapi_upload", use_container_width=True, disabled=not bool(music_path) or not status.ok):
|
|
4071
|
+
result = YTMusicApiAdapter(read_json("settings.json", {})).upload_song(music_path)
|
|
4072
|
+
_record_music_upload("ytmusicapi / YouTube Music", result, music_path=music_path, target={"service": "youtube_music"})
|
|
4073
|
+
(st.success if result.ok else st.error)(result.message)
|
|
4074
|
+
|
|
4075
|
+
|
|
4076
|
+
def render_music_upload() -> None:
|
|
4077
|
+
st.title("Upload Música")
|
|
4078
|
+
st.caption("Carregue e encaminhe músicas por JewelMusic, sincronize bibliotecas com Pushtunes ou envie directamente para YouTube Music com ytmusicapi. As credenciais e o histórico permanecem locais.")
|
|
4079
|
+
jewel_tab, pushtunes_tab, ytmusicapi_tab = render_localized_tabs(["JewelMusic", "Pushtunes", "ytmusicapi"])
|
|
4080
|
+
with jewel_tab:
|
|
4081
|
+
_render_jewelmusic_upload_tab()
|
|
4082
|
+
with pushtunes_tab:
|
|
4083
|
+
_render_pushtunes_upload_tab()
|
|
4084
|
+
with ytmusicapi_tab:
|
|
4085
|
+
_render_ytmusicapi_upload_tab()
|
|
4086
|
+
_render_music_upload_history()
|
|
4087
|
+
|
|
4088
|
+
|
|
3710
4089
|
def render_upload():
|
|
3711
4090
|
st.title("Upload")
|
|
3712
4091
|
upload_tab, direct_tab, postiz_tab, upload_post_tab = render_localized_tabs(["Upload convencional", "Upload directo", "Postiz", "Upload-Post"])
|
|
@@ -5561,7 +5940,7 @@ def main():
|
|
|
5561
5940
|
"Criação de Vídeos": render_new_video,
|
|
5562
5941
|
"Backlog Vídeos": render_videos,
|
|
5563
5942
|
"Criação de Músicas": render_music_creation,
|
|
5564
|
-
"Upload Música":
|
|
5943
|
+
"Upload Música": render_music_upload,
|
|
5565
5944
|
"Roteiros": render_scripts,
|
|
5566
5945
|
"Thumbnails": render_thumbnails,
|
|
5567
5946
|
"Upload": render_upload,
|