@danhachuel/thunderbolt 0.3.34 → 0.3.36
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/.streamlit/config.toml +1 -0
- package/MANUAL-INSTALACAO.md +29 -6
- package/README.md +22 -10
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/app/main.py +430 -84
- package/hermes_ui/api_key_tests.py +12 -0
- package/hermes_ui/languages.py +21 -0
- package/hermes_ui/notifications.py +22 -0
- package/hermes_ui/storage.py +30 -0
- package/integrations/music_uploads.py +381 -0
- package/integrations/telegram_gateway.py +158 -0
- package/package.json +1 -1
- package/requirements.txt +3 -1
package/app/main.py
CHANGED
|
@@ -3,6 +3,7 @@ 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
9
|
from datetime import date, datetime
|
|
@@ -42,7 +43,7 @@ from hermes_ui.music import list_music_files, materialize_suno_audio, request_su
|
|
|
42
43
|
from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
|
|
43
44
|
from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
|
|
44
45
|
from hermes_ui.languages import LANGUAGE_CODES, VIDEO_LANGUAGE_CODES, LANGUAGE_FLAG_DATA_URIS, language_code, language_label, ui_language_menu_label, ui_text, video_language_label, video_language_options
|
|
45
|
-
from hermes_ui.api_key_tests import test_apify_credentials, test_kaggle_credentials, test_material_source_credentials, test_nano_banana_credentials, test_postiz_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
|
|
46
|
+
from hermes_ui.api_key_tests import test_apify_credentials, test_kaggle_credentials, test_material_source_credentials, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
|
|
46
47
|
from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
|
|
47
48
|
|
|
48
49
|
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
@@ -64,6 +65,7 @@ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdap
|
|
|
64
65
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
65
66
|
from integrations.postiz import PostizAdapter
|
|
66
67
|
from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
|
|
68
|
+
from integrations.music_uploads import JewelMusicAdapter, PushtunesAdapter, YTMusicApiAdapter, MUSIC_UPLOAD_EXTENSIONS, PUSHTUNES_OPERATIONS, PUSHTUNES_SOURCES, PUSHTUNES_TARGETS, YT_MUSIC_UPLOAD_EXTENSIONS
|
|
67
69
|
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
68
70
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
69
71
|
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
|
|
@@ -3707,6 +3709,277 @@ def render_upload_direct():
|
|
|
3707
3709
|
(st.success if result.ok else st.error)(result.message)
|
|
3708
3710
|
|
|
3709
3711
|
|
|
3712
|
+
def _persist_music_upload_settings(updates: dict[str, Any]) -> dict[str, Any]:
|
|
3713
|
+
settings = read_json("settings.json", {})
|
|
3714
|
+
settings.update(updates)
|
|
3715
|
+
write_json("settings.json", settings)
|
|
3716
|
+
return settings
|
|
3717
|
+
|
|
3718
|
+
|
|
3719
|
+
def _render_music_source(prefix: str, extensions: set[str]) -> str:
|
|
3720
|
+
source_mode = st.radio(
|
|
3721
|
+
"Origem da música",
|
|
3722
|
+
["Ficheiro existente", "Carregar ficheiro"],
|
|
3723
|
+
horizontal=True,
|
|
3724
|
+
key=f"{prefix}_music_source_mode",
|
|
3725
|
+
)
|
|
3726
|
+
selected_path = str(st.session_state.get(f"{prefix}_music_path") or "")
|
|
3727
|
+
if source_mode == "Ficheiro existente":
|
|
3728
|
+
existing = [path for path in list_music_files() if path.suffix.lower() in extensions]
|
|
3729
|
+
if existing:
|
|
3730
|
+
selected = st.selectbox(
|
|
3731
|
+
"Música local",
|
|
3732
|
+
existing,
|
|
3733
|
+
index=next((index for index, item in enumerate(existing) if str(item) == selected_path), 0),
|
|
3734
|
+
format_func=lambda item: item.name,
|
|
3735
|
+
key=f"{prefix}_music_existing",
|
|
3736
|
+
)
|
|
3737
|
+
selected_path = str(selected)
|
|
3738
|
+
st.session_state[f"{prefix}_music_path"] = selected_path
|
|
3739
|
+
else:
|
|
3740
|
+
st.info("Ainda não existem ficheiros compatíveis em storage/music. Escolha Carregar ficheiro.")
|
|
3741
|
+
selected_path = ""
|
|
3742
|
+
else:
|
|
3743
|
+
uploaded = st.file_uploader(
|
|
3744
|
+
"Carregar ficheiro de música",
|
|
3745
|
+
type=sorted(extension.lstrip(".") for extension in extensions),
|
|
3746
|
+
key=f"{prefix}_music_file_upload",
|
|
3747
|
+
)
|
|
3748
|
+
if uploaded is not None and st.button("Guardar música no storage local", key=f"{prefix}_music_store", use_container_width=True):
|
|
3749
|
+
try:
|
|
3750
|
+
stored = store_music_file(uploaded.name, uploaded.getvalue())
|
|
3751
|
+
selected_path = str(stored)
|
|
3752
|
+
st.session_state[f"{prefix}_music_path"] = selected_path
|
|
3753
|
+
st.success(f"Música guardada em `{stored}`.")
|
|
3754
|
+
except (OSError, ValueError) as exc:
|
|
3755
|
+
st.error(str(exc))
|
|
3756
|
+
selected_path = str(st.session_state.get(f"{prefix}_music_path") or selected_path)
|
|
3757
|
+
if selected_path and Path(selected_path).is_file():
|
|
3758
|
+
st.caption(f"Ficheiro seleccionado: `{selected_path}`")
|
|
3759
|
+
return selected_path
|
|
3760
|
+
return ""
|
|
3761
|
+
|
|
3762
|
+
|
|
3763
|
+
def _record_music_upload(destination: str, result: IntegrationResult, *, music_path: str = "", target: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
3764
|
+
record = {
|
|
3765
|
+
"id": uuid.uuid4().hex,
|
|
3766
|
+
"destination": destination,
|
|
3767
|
+
"music_path": music_path,
|
|
3768
|
+
"target": target or {},
|
|
3769
|
+
"status": "published" if result.ok else "failed",
|
|
3770
|
+
"message": result.message,
|
|
3771
|
+
"data": result.data,
|
|
3772
|
+
"created_at": now(),
|
|
3773
|
+
}
|
|
3774
|
+
uploads = read_json("uploads.json", [])
|
|
3775
|
+
uploads.append(record)
|
|
3776
|
+
write_json("uploads.json", uploads)
|
|
3777
|
+
reconcile_persisted_notifications()
|
|
3778
|
+
return record
|
|
3779
|
+
|
|
3780
|
+
|
|
3781
|
+
def _render_music_upload_history() -> None:
|
|
3782
|
+
records = [
|
|
3783
|
+
record
|
|
3784
|
+
for record in read_json("uploads.json", [])
|
|
3785
|
+
if isinstance(record, dict)
|
|
3786
|
+
and any(name in str(record.get("destination") or "").lower() for name in ("jewelmusic", "pushtunes", "youtube music", "ytmusicapi"))
|
|
3787
|
+
]
|
|
3788
|
+
st.divider()
|
|
3789
|
+
st.subheader("Histórico de uploads de música")
|
|
3790
|
+
if not records:
|
|
3791
|
+
st.caption("Ainda não existem uploads de música registados.")
|
|
3792
|
+
return
|
|
3793
|
+
for record in reversed(records[-20:]):
|
|
3794
|
+
status = "Concluído" if record.get("status") == "published" else "Falhou"
|
|
3795
|
+
path = Path(str(record.get("music_path") or ""))
|
|
3796
|
+
with st.container(border=True):
|
|
3797
|
+
st.write(f"**{record.get('destination', 'Upload musical')}** · {status}")
|
|
3798
|
+
st.caption(f"{record.get('created_at', '—')} · {path.name if path.name else record.get('target', {})}")
|
|
3799
|
+
st.write(record.get("message", ""))
|
|
3800
|
+
if path.is_file():
|
|
3801
|
+
st.download_button(
|
|
3802
|
+
"Descarregar cópia local",
|
|
3803
|
+
data=path.read_bytes(),
|
|
3804
|
+
file_name=path.name,
|
|
3805
|
+
mime=mimetypes.guess_type(path.name)[0] or "audio/mpeg",
|
|
3806
|
+
key=f"music_history_download_{record.get('id')}",
|
|
3807
|
+
)
|
|
3808
|
+
|
|
3809
|
+
|
|
3810
|
+
def _render_jewelmusic_upload_tab() -> None:
|
|
3811
|
+
st.subheader("JewelMusic")
|
|
3812
|
+
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.")
|
|
3813
|
+
st.info("A API Key é guardada apenas no settings.json local. O teste consulta /v1/ping e não cria uma track.")
|
|
3814
|
+
settings = read_json("settings.json", {})
|
|
3815
|
+
with st.form("jewelmusic_settings_form"):
|
|
3816
|
+
enabled = st.checkbox("Activar JewelMusic", value=bool(settings.get("jewelmusic_enabled", False)))
|
|
3817
|
+
api_key = st.text_input("JewelMusic API Key", value=str(settings.get("jewelmusic_api_key") or ""), type="password")
|
|
3818
|
+
base_url = st.text_input("Base URL", value=str(settings.get("jewelmusic_base_url") or "https://api.jewelmusic.com"))
|
|
3819
|
+
proxy_url = st.text_input("Proxy opcional", value=str(settings.get("jewelmusic_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
|
|
3820
|
+
timeout_seconds = st.number_input("Timeout (segundos)", min_value=5, max_value=900, value=int(settings.get("jewelmusic_timeout_seconds", 120)), step=5)
|
|
3821
|
+
save = st.form_submit_button("Guardar configuração JewelMusic", type="primary", use_container_width=True)
|
|
3822
|
+
if save:
|
|
3823
|
+
settings = _persist_music_upload_settings({
|
|
3824
|
+
"jewelmusic_enabled": bool(enabled),
|
|
3825
|
+
"jewelmusic_api_key": api_key.strip(),
|
|
3826
|
+
"jewelmusic_base_url": base_url.strip().rstrip("/"),
|
|
3827
|
+
"jewelmusic_proxy_url": proxy_url.strip(),
|
|
3828
|
+
"jewelmusic_timeout_seconds": int(timeout_seconds),
|
|
3829
|
+
})
|
|
3830
|
+
st.success("Configuração JewelMusic guardada no storage local.")
|
|
3831
|
+
adapter = JewelMusicAdapter(settings)
|
|
3832
|
+
if st.button("Testar conexão JewelMusic", key="jewelmusic_test", use_container_width=True):
|
|
3833
|
+
test_result = adapter.test_connection()
|
|
3834
|
+
(st.success if test_result.ok else st.error)(test_result.message)
|
|
3835
|
+
music_path = _render_music_source("jewelmusic", MUSIC_UPLOAD_EXTENSIONS)
|
|
3836
|
+
metadata_cols = st.columns(4)
|
|
3837
|
+
with metadata_cols[0]:
|
|
3838
|
+
title = st.text_input("Título", value=Path(music_path).stem if music_path else "", key="jewelmusic_title")
|
|
3839
|
+
with metadata_cols[1]:
|
|
3840
|
+
artist = st.text_input("Artista", key="jewelmusic_artist")
|
|
3841
|
+
with metadata_cols[2]:
|
|
3842
|
+
album = st.text_input("Álbum", key="jewelmusic_album")
|
|
3843
|
+
with metadata_cols[3]:
|
|
3844
|
+
year = st.text_input("Ano", key="jewelmusic_year")
|
|
3845
|
+
genre = st.text_input("Género", key="jewelmusic_genre")
|
|
3846
|
+
if st.button("Enviar música para JewelMusic", type="primary", key="jewelmusic_upload", use_container_width=True, disabled=not bool(music_path)):
|
|
3847
|
+
result = JewelMusicAdapter(read_json("settings.json", {})).upload_track(music_path, title=title, artist=artist, album=album, year=year, genre=genre)
|
|
3848
|
+
_record_music_upload("JewelMusic", result, music_path=music_path, target={"artist": artist, "title": title})
|
|
3849
|
+
(st.success if result.ok else st.error)(result.message)
|
|
3850
|
+
|
|
3851
|
+
|
|
3852
|
+
def _store_pushtunes_csv(uploaded: Any) -> str:
|
|
3853
|
+
target = STORAGE / "music" / "pushtunes-source.csv"
|
|
3854
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3855
|
+
target.write_bytes(uploaded.getvalue())
|
|
3856
|
+
return str(target)
|
|
3857
|
+
|
|
3858
|
+
|
|
3859
|
+
def _render_pushtunes_upload_tab() -> None:
|
|
3860
|
+
st.subheader("Pushtunes")
|
|
3861
|
+
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.")
|
|
3862
|
+
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.")
|
|
3863
|
+
settings = read_json("settings.json", {})
|
|
3864
|
+
with st.form("pushtunes_settings_form"):
|
|
3865
|
+
enabled = st.checkbox("Activar Pushtunes", value=bool(settings.get("pushtunes_enabled", False)))
|
|
3866
|
+
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.")
|
|
3867
|
+
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))
|
|
3868
|
+
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))
|
|
3869
|
+
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))
|
|
3870
|
+
profile = st.text_input("Perfil Pushtunes (.toml), opcional", value=str(settings.get("pushtunes_profile") or ""))
|
|
3871
|
+
csv_file = st.text_input("Caminho CSV", value=str(settings.get("pushtunes_csv_file") or ""))
|
|
3872
|
+
ytm_auth_file = st.text_input("Caminho browser.json do YouTube Music", value=str(settings.get("pushtunes_ytm_auth_file") or ""))
|
|
3873
|
+
tidal_session_file = st.text_input("Caminho tidal-session.json do Tidal", value=str(settings.get("pushtunes_tidal_session_file") or ""))
|
|
3874
|
+
playlist_name = st.text_input("Nome da playlist", value=str(settings.get("pushtunes_playlist_name") or ""))
|
|
3875
|
+
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)
|
|
3876
|
+
working_directory = st.text_input("Directório de trabalho", value=str(settings.get("pushtunes_working_directory") or ""))
|
|
3877
|
+
spotify_client_id = st.text_input("Spotify Client ID", value=str(settings.get("pushtunes_spotify_client_id") or ""))
|
|
3878
|
+
spotify_client_secret = st.text_input("Spotify Client Secret", value=str(settings.get("pushtunes_spotify_client_secret") or ""), type="password")
|
|
3879
|
+
spotify_redirect_uri = st.text_input("Spotify Redirect URI", value=str(settings.get("pushtunes_spotify_redirect_uri") or ""))
|
|
3880
|
+
timeout_seconds = st.number_input("Timeout Pushtunes (segundos)", min_value=30, max_value=3600, value=int(settings.get("pushtunes_timeout_seconds", 1800)), step=30)
|
|
3881
|
+
save = st.form_submit_button("Guardar configuração Pushtunes", type="primary", use_container_width=True)
|
|
3882
|
+
if save:
|
|
3883
|
+
settings = _persist_music_upload_settings({
|
|
3884
|
+
"pushtunes_enabled": bool(enabled),
|
|
3885
|
+
"pushtunes_executable": executable.strip() or "pushtunes",
|
|
3886
|
+
"pushtunes_source": source,
|
|
3887
|
+
"pushtunes_target": target,
|
|
3888
|
+
"pushtunes_operation": operation,
|
|
3889
|
+
"pushtunes_profile": profile.strip(),
|
|
3890
|
+
"pushtunes_csv_file": csv_file.strip(),
|
|
3891
|
+
"pushtunes_ytm_auth_file": ytm_auth_file.strip(),
|
|
3892
|
+
"pushtunes_tidal_session_file": tidal_session_file.strip(),
|
|
3893
|
+
"pushtunes_playlist_name": playlist_name.strip(),
|
|
3894
|
+
"pushtunes_similarity": float(similarity),
|
|
3895
|
+
"pushtunes_working_directory": working_directory.strip(),
|
|
3896
|
+
"pushtunes_spotify_client_id": spotify_client_id.strip(),
|
|
3897
|
+
"pushtunes_spotify_client_secret": spotify_client_secret.strip(),
|
|
3898
|
+
"pushtunes_spotify_redirect_uri": spotify_redirect_uri.strip(),
|
|
3899
|
+
"pushtunes_timeout_seconds": int(timeout_seconds),
|
|
3900
|
+
})
|
|
3901
|
+
st.success("Configuração Pushtunes guardada no storage local.")
|
|
3902
|
+
if source == "csv":
|
|
3903
|
+
st.caption("Pode carregar o CSV nesta página e usar o caminho guardado como origem Pushtunes.")
|
|
3904
|
+
csv_upload = st.file_uploader("Carregar CSV de origem Pushtunes", type=["csv"], key="pushtunes_csv_upload")
|
|
3905
|
+
if csv_upload is not None and st.button("Guardar CSV Pushtunes no storage", key="pushtunes_csv_store", use_container_width=True):
|
|
3906
|
+
stored_csv = _store_pushtunes_csv(csv_upload)
|
|
3907
|
+
settings = _persist_music_upload_settings({"pushtunes_csv_file": stored_csv})
|
|
3908
|
+
st.success(f"CSV guardado em `{stored_csv}`.")
|
|
3909
|
+
adapter = PushtunesAdapter(read_json("settings.json", {}))
|
|
3910
|
+
status = adapter.status()
|
|
3911
|
+
(st.success if status.ok else st.warning)(status.message)
|
|
3912
|
+
if st.button("Validar instalação e configuração Pushtunes", key="pushtunes_test", use_container_width=True):
|
|
3913
|
+
check = PushtunesAdapter(read_json("settings.json", {})).status()
|
|
3914
|
+
(st.success if check.ok else st.error)(check.message)
|
|
3915
|
+
if st.button("Executar sincronização Pushtunes", type="primary", key="pushtunes_sync", use_container_width=True, disabled=not status.ok):
|
|
3916
|
+
result = PushtunesAdapter(read_json("settings.json", {})).sync()
|
|
3917
|
+
_record_music_upload(f"Pushtunes ({adapter.source} → {adapter.target})", result, target={"source": adapter.source, "target": adapter.target, "operation": adapter.operation})
|
|
3918
|
+
(st.success if result.ok else st.error)(result.message)
|
|
3919
|
+
if result.data.get("stdout") or result.data.get("stderr"):
|
|
3920
|
+
with st.expander("Saída técnica do Pushtunes"):
|
|
3921
|
+
st.text(result.data.get("stdout", ""))
|
|
3922
|
+
if result.data.get("stderr"):
|
|
3923
|
+
st.text(result.data["stderr"])
|
|
3924
|
+
|
|
3925
|
+
|
|
3926
|
+
def _store_ytmusicapi_auth(uploaded: Any) -> str:
|
|
3927
|
+
target = STORAGE / "ytmusicapi" / "browser.json"
|
|
3928
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3929
|
+
target.write_bytes(uploaded.getvalue())
|
|
3930
|
+
return str(target)
|
|
3931
|
+
|
|
3932
|
+
|
|
3933
|
+
def _render_ytmusicapi_upload_tab() -> None:
|
|
3934
|
+
st.subheader("ytmusicapi")
|
|
3935
|
+
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.")
|
|
3936
|
+
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.")
|
|
3937
|
+
settings = read_json("settings.json", {})
|
|
3938
|
+
with st.form("ytmusicapi_settings_form"):
|
|
3939
|
+
enabled = st.checkbox("Activar ytmusicapi", value=bool(settings.get("ytmusicapi_enabled", False)))
|
|
3940
|
+
auth_file = st.text_input("Caminho do browser.json", value=str(settings.get("ytmusicapi_auth_file") or ""))
|
|
3941
|
+
proxy_url = st.text_input("Proxy opcional", value=str(settings.get("ytmusicapi_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
|
|
3942
|
+
timeout_seconds = st.number_input("Timeout (segundos)", min_value=30, max_value=900, value=int(settings.get("ytmusicapi_timeout_seconds", 240)), step=10)
|
|
3943
|
+
save = st.form_submit_button("Guardar configuração ytmusicapi", type="primary", use_container_width=True)
|
|
3944
|
+
if save:
|
|
3945
|
+
settings = _persist_music_upload_settings({
|
|
3946
|
+
"ytmusicapi_enabled": bool(enabled),
|
|
3947
|
+
"ytmusicapi_auth_file": auth_file.strip(),
|
|
3948
|
+
"ytmusicapi_proxy_url": proxy_url.strip(),
|
|
3949
|
+
"ytmusicapi_timeout_seconds": int(timeout_seconds),
|
|
3950
|
+
})
|
|
3951
|
+
st.success("Configuração ytmusicapi guardada no storage local.")
|
|
3952
|
+
auth_upload = st.file_uploader("Carregar browser.json", type=["json"], key="ytmusicapi_auth_upload")
|
|
3953
|
+
if auth_upload is not None and st.button("Guardar browser.json no storage local", key="ytmusicapi_auth_store", use_container_width=True):
|
|
3954
|
+
stored_auth = _store_ytmusicapi_auth(auth_upload)
|
|
3955
|
+
settings = _persist_music_upload_settings({"ytmusicapi_auth_file": stored_auth, "ytmusicapi_enabled": True})
|
|
3956
|
+
st.success(f"Ficheiro de autenticação guardado em `{stored_auth}`.")
|
|
3957
|
+
adapter = YTMusicApiAdapter(read_json("settings.json", {}))
|
|
3958
|
+
status = adapter.status()
|
|
3959
|
+
(st.success if status.ok else st.warning)(status.message)
|
|
3960
|
+
if st.button("Testar autenticação ytmusicapi", key="ytmusicapi_test", use_container_width=True):
|
|
3961
|
+
result = YTMusicApiAdapter(read_json("settings.json", {})).test_connection()
|
|
3962
|
+
(st.success if result.ok else st.error)(result.message)
|
|
3963
|
+
music_path = _render_music_source("ytmusicapi", YT_MUSIC_UPLOAD_EXTENSIONS)
|
|
3964
|
+
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):
|
|
3965
|
+
result = YTMusicApiAdapter(read_json("settings.json", {})).upload_song(music_path)
|
|
3966
|
+
_record_music_upload("ytmusicapi / YouTube Music", result, music_path=music_path, target={"service": "youtube_music"})
|
|
3967
|
+
(st.success if result.ok else st.error)(result.message)
|
|
3968
|
+
|
|
3969
|
+
|
|
3970
|
+
def render_music_upload() -> None:
|
|
3971
|
+
st.title("Upload Música")
|
|
3972
|
+
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.")
|
|
3973
|
+
jewel_tab, pushtunes_tab, ytmusicapi_tab = render_localized_tabs(["JewelMusic", "Pushtunes", "ytmusicapi"])
|
|
3974
|
+
with jewel_tab:
|
|
3975
|
+
_render_jewelmusic_upload_tab()
|
|
3976
|
+
with pushtunes_tab:
|
|
3977
|
+
_render_pushtunes_upload_tab()
|
|
3978
|
+
with ytmusicapi_tab:
|
|
3979
|
+
_render_ytmusicapi_upload_tab()
|
|
3980
|
+
_render_music_upload_history()
|
|
3981
|
+
|
|
3982
|
+
|
|
3710
3983
|
def render_upload():
|
|
3711
3984
|
st.title("Upload")
|
|
3712
3985
|
upload_tab, direct_tab, postiz_tab, upload_post_tab = render_localized_tabs(["Upload convencional", "Upload directo", "Postiz", "Upload-Post"])
|
|
@@ -4955,93 +5228,166 @@ def render_settings():
|
|
|
4955
5228
|
st.warning("A amostra de voz anterior não é um ficheiro de áudio legível e foi removida do estado local. Teste a voz novamente.")
|
|
4956
5229
|
|
|
4957
5230
|
|
|
5231
|
+
def _render_telegram_notification_settings() -> None:
|
|
5232
|
+
"""Render Telegram outbound notification settings and a safe diagnostics action."""
|
|
5233
|
+
settings = read_json("settings.json", {})
|
|
5234
|
+
if not isinstance(settings, dict):
|
|
5235
|
+
settings = {}
|
|
5236
|
+
st.subheader("Telegram Gateway")
|
|
5237
|
+
st.caption("Envie para o Telegram as mesmas notificações que aparecem na subaba Geral. A integração usa a Bot API oficial e não recebe mensagens nem inicia polling.")
|
|
5238
|
+
with st.form("telegram_notifications_form"):
|
|
5239
|
+
telegram_enabled = st.checkbox(
|
|
5240
|
+
"Activar notificações Telegram",
|
|
5241
|
+
value=bool(settings.get("telegram_enabled", False)),
|
|
5242
|
+
help="Quando activo, cada nova notificação permitida em Geral é enviada para o Chat ID configurado.",
|
|
5243
|
+
)
|
|
5244
|
+
telegram_cols = st.columns(2)
|
|
5245
|
+
with telegram_cols[0]:
|
|
5246
|
+
telegram_bot_token = st.text_input(
|
|
5247
|
+
"Telegram Bot Token",
|
|
5248
|
+
value=str(settings.get("telegram_bot_token") or ""),
|
|
5249
|
+
type="password",
|
|
5250
|
+
help="Token criado pelo BotFather. Fica guardado apenas no storage local.",
|
|
5251
|
+
)
|
|
5252
|
+
_render_credential_status(telegram_bot_token)
|
|
5253
|
+
with telegram_cols[1]:
|
|
5254
|
+
telegram_chat_id = st.text_input(
|
|
5255
|
+
"Telegram Chat ID",
|
|
5256
|
+
value=str(settings.get("telegram_chat_id") or ""),
|
|
5257
|
+
help="ID do utilizador, grupo ou canal que receberá as notificações; também pode ser um username aceite pelo Telegram.",
|
|
5258
|
+
)
|
|
5259
|
+
telegram_proxy_url = st.text_input(
|
|
5260
|
+
"Proxy Telegram (opcional)",
|
|
5261
|
+
value=str(settings.get("telegram_proxy_url") or ""),
|
|
5262
|
+
help="Proxy HTTP/HTTPS/SOCKS suportado pelo ambiente, quando o acesso directo ao Telegram não estiver disponível.",
|
|
5263
|
+
)
|
|
5264
|
+
telegram_timeout_seconds = st.number_input(
|
|
5265
|
+
"Timeout de envio (segundos)",
|
|
5266
|
+
min_value=5,
|
|
5267
|
+
max_value=120,
|
|
5268
|
+
value=int(settings.get("telegram_timeout_seconds", 15) or 15),
|
|
5269
|
+
step=5,
|
|
5270
|
+
)
|
|
5271
|
+
st.caption("O teste consulta getMe para validar o Bot Token e não envia uma mensagem de teste.")
|
|
5272
|
+
_render_api_test_control(
|
|
5273
|
+
settings,
|
|
5274
|
+
"telegram",
|
|
5275
|
+
lambda: test_telegram_credentials(telegram_bot_token, telegram_chat_id),
|
|
5276
|
+
widget_key="api_test_telegram",
|
|
5277
|
+
)
|
|
5278
|
+
if st.form_submit_button("Guardar configuração Telegram", type="primary", use_container_width=True):
|
|
5279
|
+
settings.update({
|
|
5280
|
+
"telegram_enabled": bool(telegram_enabled),
|
|
5281
|
+
"telegram_bot_token": telegram_bot_token.strip(),
|
|
5282
|
+
"telegram_chat_id": telegram_chat_id.strip(),
|
|
5283
|
+
"telegram_proxy_url": telegram_proxy_url.strip(),
|
|
5284
|
+
"telegram_timeout_seconds": int(telegram_timeout_seconds),
|
|
5285
|
+
})
|
|
5286
|
+
write_json("settings.json", settings)
|
|
5287
|
+
st.success("Configuração Telegram guardada no storage local.")
|
|
5288
|
+
st.rerun()
|
|
5289
|
+
|
|
5290
|
+
if telegram_enabled and telegram_bot_token.strip() and telegram_chat_id.strip():
|
|
5291
|
+
st.success("Telegram está preparado para receber novas notificações.")
|
|
5292
|
+
elif telegram_enabled:
|
|
5293
|
+
st.warning("Telegram está activo, mas ainda falta configurar o Bot Token e o Chat ID.")
|
|
5294
|
+
else:
|
|
5295
|
+
st.info("Telegram está desactivado. As notificações continuam disponíveis na subaba Geral.")
|
|
5296
|
+
|
|
5297
|
+
|
|
4958
5298
|
def render_notifications():
|
|
4959
5299
|
st.title("Notificações")
|
|
4960
5300
|
st.caption("Centro de notificações internas persistentes do Thunderbolt. As conclusões são guardadas no storage local e aparecem quando a aplicação é actualizada.")
|
|
4961
|
-
|
|
4962
|
-
preferences = notification_preferences()
|
|
4963
|
-
catalog = notification_event_catalog()
|
|
4964
|
-
notifications = list_notifications(limit=500)
|
|
4965
|
-
unread_count = unread_notification_count()
|
|
4966
|
-
summary_cols = st.columns(3)
|
|
4967
|
-
with summary_cols[0]:
|
|
4968
|
-
st.metric("Não lidas", unread_count)
|
|
4969
|
-
with summary_cols[1]:
|
|
4970
|
-
st.metric("Total guardado", len(notifications))
|
|
4971
|
-
with summary_cols[2]:
|
|
4972
|
-
st.metric("Operações mapeadas", len(catalog))
|
|
4973
|
-
|
|
4974
|
-
action_cols = st.columns([1.4, 1.4, 2.2])
|
|
4975
|
-
with action_cols[0]:
|
|
4976
|
-
if st.button("Marcar todas como lidas", use_container_width=True, disabled=unread_count == 0):
|
|
4977
|
-
mark_all_notifications_read()
|
|
4978
|
-
st.rerun()
|
|
4979
|
-
with action_cols[1]:
|
|
4980
|
-
if st.button("Actualizar notificações", use_container_width=True):
|
|
4981
|
-
reconcile_persisted_notifications()
|
|
4982
|
-
st.rerun()
|
|
4983
|
-
with action_cols[2]:
|
|
4984
|
-
confirm_clear = st.checkbox("Confirmar limpeza do histórico", key="confirm_clear_notifications")
|
|
4985
|
-
if st.button("Limpar histórico", use_container_width=True, disabled=not confirm_clear):
|
|
4986
|
-
clear_notifications()
|
|
4987
|
-
st.session_state.pop("confirm_clear_notifications", None)
|
|
4988
|
-
st.rerun()
|
|
5301
|
+
general_tab, telegram_tab = render_localized_tabs(["Geral", "Telegram"])
|
|
4989
5302
|
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
st.
|
|
5303
|
+
with general_tab:
|
|
5304
|
+
reconcile_persisted_notifications()
|
|
5305
|
+
preferences = notification_preferences()
|
|
5306
|
+
catalog = notification_event_catalog()
|
|
5307
|
+
notifications = list_notifications(limit=500)
|
|
5308
|
+
unread_count = unread_notification_count()
|
|
5309
|
+
summary_cols = st.columns(3)
|
|
5310
|
+
with summary_cols[0]:
|
|
5311
|
+
st.metric("Não lidas", unread_count)
|
|
5312
|
+
with summary_cols[1]:
|
|
5313
|
+
st.metric("Total guardado", len(notifications))
|
|
5314
|
+
with summary_cols[2]:
|
|
5315
|
+
st.metric("Operações mapeadas", len(catalog))
|
|
5316
|
+
|
|
5317
|
+
action_cols = st.columns([1.4, 1.4, 2.2])
|
|
5318
|
+
with action_cols[0]:
|
|
5319
|
+
if st.button("Marcar todas como lidas", use_container_width=True, disabled=unread_count == 0):
|
|
5320
|
+
mark_all_notifications_read()
|
|
5321
|
+
st.rerun()
|
|
5322
|
+
with action_cols[1]:
|
|
5323
|
+
if st.button("Actualizar notificações", use_container_width=True):
|
|
5324
|
+
reconcile_persisted_notifications()
|
|
5325
|
+
st.rerun()
|
|
5326
|
+
with action_cols[2]:
|
|
5327
|
+
confirm_clear = st.checkbox("Confirmar limpeza do histórico", key="confirm_clear_notifications")
|
|
5328
|
+
if st.button("Limpar histórico", use_container_width=True, disabled=not confirm_clear):
|
|
5329
|
+
clear_notifications()
|
|
5330
|
+
st.session_state.pop("confirm_clear_notifications", None)
|
|
5331
|
+
st.rerun()
|
|
5011
5332
|
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
st.
|
|
5032
|
-
st.
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5333
|
+
st.divider()
|
|
5334
|
+
st.subheader("Operações notificadas")
|
|
5335
|
+
st.caption("Ligue ou desligue cada tipo de notificação. As preferências ficam guardadas no storage local e aplicam-se às próximas conclusões.")
|
|
5336
|
+
grouped: dict[str, list[dict[str, str]]] = {}
|
|
5337
|
+
for event in catalog:
|
|
5338
|
+
grouped.setdefault(event["category"], []).append(event)
|
|
5339
|
+
with st.form("notification_preferences_form"):
|
|
5340
|
+
pending_preferences: dict[str, bool] = {}
|
|
5341
|
+
for category, events in grouped.items():
|
|
5342
|
+
st.markdown(f"**{category}**")
|
|
5343
|
+
for event in events:
|
|
5344
|
+
pending_preferences[event["code"]] = st.checkbox(
|
|
5345
|
+
event["label"],
|
|
5346
|
+
value=bool(preferences.get(event["code"], True)),
|
|
5347
|
+
help=event["description"],
|
|
5348
|
+
key=f"notification_preference_{event['code']}",
|
|
5349
|
+
)
|
|
5350
|
+
if st.form_submit_button("Guardar preferências", type="primary", use_container_width=True):
|
|
5351
|
+
save_notification_preferences(pending_preferences)
|
|
5352
|
+
st.success("Preferências de notificação guardadas.")
|
|
5353
|
+
st.rerun()
|
|
5354
|
+
|
|
5355
|
+
st.divider()
|
|
5356
|
+
st.subheader("Histórico de notificações")
|
|
5357
|
+
filter_cols = st.columns([1, 1.4])
|
|
5358
|
+
category_options = ["Todas"] + sorted({event["category"] for event in catalog})
|
|
5359
|
+
with filter_cols[0]:
|
|
5360
|
+
selected_category = st.selectbox("Categoria", category_options, key="notifications_category_filter")
|
|
5361
|
+
with filter_cols[1]:
|
|
5362
|
+
selected_state = st.selectbox("Estado", ["Todas", "Não lidas", "Lidas"], key="notifications_state_filter")
|
|
5363
|
+
category_filter = "" if selected_category == "Todas" else selected_category
|
|
5364
|
+
unread_filter = selected_state == "Não lidas"
|
|
5365
|
+
filtered = list_notifications(limit=500, category=category_filter, unread_only=unread_filter)
|
|
5366
|
+
if selected_state == "Lidas":
|
|
5367
|
+
filtered = [item for item in filtered if item.get("read")]
|
|
5368
|
+
if not filtered:
|
|
5369
|
+
st.info("Ainda não existem notificações para os filtros seleccionados.")
|
|
5370
|
+
for item in filtered:
|
|
5371
|
+
with st.container(border=True):
|
|
5372
|
+
notification_cols = st.columns([3.3, 1.4, 1])
|
|
5373
|
+
with notification_cols[0]:
|
|
5374
|
+
st.write(f"**{item.get('title') or item.get('label') or 'Notificação'}**")
|
|
5375
|
+
st.caption(item.get("message") or "")
|
|
5376
|
+
metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
|
|
5377
|
+
if metadata:
|
|
5378
|
+
public_details = " · ".join(f"{key}: {value}" for key, value in metadata.items() if value not in (None, ""))
|
|
5379
|
+
if public_details:
|
|
5380
|
+
st.caption(public_details)
|
|
5381
|
+
with notification_cols[1]:
|
|
5382
|
+
st.caption(f"{item.get('category', 'Sistema')} · {item.get('created_at', '—')}")
|
|
5383
|
+
st.write("Lida" if item.get("read") else "Não lida")
|
|
5384
|
+
with notification_cols[2]:
|
|
5385
|
+
if not item.get("read") and st.button("Marcar como lida", key=f"mark_notification_{item.get('id')}", use_container_width=True):
|
|
5386
|
+
mark_notification_read(str(item.get("id")))
|
|
5387
|
+
st.rerun()
|
|
5388
|
+
|
|
5389
|
+
with telegram_tab:
|
|
5390
|
+
_render_telegram_notification_settings()
|
|
5045
5391
|
|
|
5046
5392
|
|
|
5047
5393
|
def render_models_ai_tutorial():
|
|
@@ -5488,7 +5834,7 @@ def main():
|
|
|
5488
5834
|
"Criação de Vídeos": render_new_video,
|
|
5489
5835
|
"Backlog Vídeos": render_videos,
|
|
5490
5836
|
"Criação de Músicas": render_music_creation,
|
|
5491
|
-
"Upload Música":
|
|
5837
|
+
"Upload Música": render_music_upload,
|
|
5492
5838
|
"Roteiros": render_scripts,
|
|
5493
5839
|
"Thumbnails": render_thumbnails,
|
|
5494
5840
|
"Upload": render_upload,
|
|
@@ -213,6 +213,18 @@ def test_postiz_credentials(api_key: str, base_url: str = "https://api.postiz.co
|
|
|
213
213
|
return _get(f"{base}/integrations", headers={"Authorization": api_key})
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
def test_telegram_credentials(bot_token: str, chat_id: str) -> dict[str, Any]:
|
|
217
|
+
"""Validate the Telegram bot token with getMe without sending a message."""
|
|
218
|
+
bot_token = str(bot_token or "").strip()
|
|
219
|
+
chat_id = str(chat_id or "").strip()
|
|
220
|
+
if not bot_token or not chat_id:
|
|
221
|
+
return _missing("Introduza o Bot Token e o Chat ID Telegram antes de testar.")
|
|
222
|
+
result = _get(f"https://api.telegram.org/bot{bot_token}/getMe")
|
|
223
|
+
if result.get("ok"):
|
|
224
|
+
result["message"] = "Bot Token Telegram válido. O Chat ID foi aceite localmente; o teste não enviou mensagem."
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
|
|
216
228
|
def test_material_source_credentials(provider: str, api_key: str) -> dict[str, Any]:
|
|
217
229
|
"""Run a read-only provider-specific check for material source cards."""
|
|
218
230
|
provider = str(provider or "").strip().lower()
|
package/hermes_ui/languages.py
CHANGED
|
@@ -614,6 +614,27 @@ for _language_code, _tab_translation in TAB_TRANSLATIONS.items():
|
|
|
614
614
|
UI_TRANSLATIONS[_language_code].update(_tab_translation)
|
|
615
615
|
|
|
616
616
|
|
|
617
|
+
_NOTIFICATION_TAB_TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
618
|
+
"pt": {"Geral": "Geral", "Telegram": "Telegram"},
|
|
619
|
+
"en": {"Geral": "General", "Telegram": "Telegram"},
|
|
620
|
+
"zh": {"Geral": "常规", "Telegram": "Telegram"},
|
|
621
|
+
"de": {"Geral": "Allgemein", "Telegram": "Telegram"},
|
|
622
|
+
"vi": {"Geral": "Chung", "Telegram": "Telegram"},
|
|
623
|
+
"tr": {"Geral": "Genel", "Telegram": "Telegram"},
|
|
624
|
+
"ru": {"Geral": "Общие", "Telegram": "Telegram"},
|
|
625
|
+
"es": {"Geral": "General", "Telegram": "Telegram"},
|
|
626
|
+
"id": {"Geral": "Umum", "Telegram": "Telegram"},
|
|
627
|
+
"it": {"Geral": "Generale", "Telegram": "Telegram"},
|
|
628
|
+
"pl": {"Geral": "Ogólne", "Telegram": "Telegram"},
|
|
629
|
+
"ga": {"Geral": "Ginearálta", "Telegram": "Telegram"},
|
|
630
|
+
"ar": {"Geral": "عام", "Telegram": "Telegram"},
|
|
631
|
+
"he": {"Geral": "כללי", "Telegram": "Telegram"},
|
|
632
|
+
}
|
|
633
|
+
for _language_code, _notification_tab_translation in _NOTIFICATION_TAB_TRANSLATIONS.items():
|
|
634
|
+
if _language_code in UI_TRANSLATIONS:
|
|
635
|
+
UI_TRANSLATIONS[_language_code].update(_notification_tab_translation)
|
|
636
|
+
|
|
637
|
+
|
|
617
638
|
TUTORIAL_TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
618
639
|
"pt": {"Tutorial Supabase": "Tutorial Supabase", "Guia de configuração do Supabase para automações com n8n.": "Guia de configuração do Supabase para automações com n8n."},
|
|
619
640
|
"en": {"Tutorial Supabase": "Supabase Tutorial", "Guia de configuração do Supabase para automações com n8n.": "Supabase setup guide for n8n automations."},
|