@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/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]: st.write(task.get("stage", "—"))
3343
- with cols[3]: st.write(task.get("state", "—"))
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": lambda: render_edit_placeholder("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,