@danhachuel/thunderbolt 0.4.45 → 0.4.47
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 +120 -14
- package/hermes_ui/creative_generation.py +41 -0
- package/integrations/youtube_update.py +107 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -80,10 +80,11 @@ from hermes_ui.thumbnails import (
|
|
|
80
80
|
upload_thumbnail_image,
|
|
81
81
|
)
|
|
82
82
|
from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields, missing_setting_sections, normalise_saved_script, setting_widget_suffixes
|
|
83
|
-
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_title_and_keywords, generate_topic_for_channel, generate_video_description, generate_video_keywords
|
|
83
|
+
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_title_and_keywords, generate_topic_for_channel, generate_video_description, generate_video_keywords, generate_video_update_metadata
|
|
84
84
|
from hermes_ui.media_generation import MediaGenerationError, generate_image_for_card, generate_video_for_card
|
|
85
85
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
86
86
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
87
|
+
from integrations.youtube_update import YOUTUBE_UPDATE_VIDEO, YouTubeVideoUpdater
|
|
87
88
|
from integrations.postiz import PostizAdapter
|
|
88
89
|
from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
|
|
89
90
|
from integrations.bilibili_upload import BilibiliApiAdapter, BILIBILI_DEFAULT_TID, BILIBILI_VIDEO_EXTENSIONS, normalise_bilibili_api_cards
|
|
@@ -1857,6 +1858,16 @@ def _tiktok_channel_records() -> list[dict[str, Any]]:
|
|
|
1857
1858
|
return [channel for channel in read_json("channels.json", []) if isinstance(channel, dict) and channel.get("platform") == "tiktok"]
|
|
1858
1859
|
|
|
1859
1860
|
|
|
1861
|
+
def _tiktok_avatar_url(value: dict[str, Any]) -> str:
|
|
1862
|
+
for key in ("avatar_url", "thumbnail_url", "profile_image_url", "avatar", "image_url"):
|
|
1863
|
+
candidate = value.get(key)
|
|
1864
|
+
if isinstance(candidate, dict):
|
|
1865
|
+
candidate = candidate.get("url") or candidate.get("src")
|
|
1866
|
+
if str(candidate or "").strip():
|
|
1867
|
+
return str(candidate).strip()
|
|
1868
|
+
return ""
|
|
1869
|
+
|
|
1870
|
+
|
|
1860
1871
|
def _tiktok_prompt_options() -> tuple[list[str], dict[str, str]]:
|
|
1861
1872
|
files = list_prompt_master_files()
|
|
1862
1873
|
labels = {path.name: get_display_name("prompt_masters", path, path.stem) for path in files}
|
|
@@ -1892,7 +1903,7 @@ def render_tiktok_channels():
|
|
|
1892
1903
|
with st.container(border=True):
|
|
1893
1904
|
profile_cols = st.columns([0.8, 2.2, 1, 1, 1])
|
|
1894
1905
|
with profile_cols[0]:
|
|
1895
|
-
avatar =
|
|
1906
|
+
avatar = _tiktok_avatar_url(imported)
|
|
1896
1907
|
if avatar:
|
|
1897
1908
|
st.image(avatar, width=82)
|
|
1898
1909
|
else:
|
|
@@ -1931,7 +1942,8 @@ def render_tiktok_channels():
|
|
|
1931
1942
|
description = st.text_area("Descrição", value=str(imported.get("bio") or ""), key="tiktok_import_description")
|
|
1932
1943
|
if st.form_submit_button("Cadastrar canal TikTok", type="primary"):
|
|
1933
1944
|
channel = create_channel(name.strip(), url.strip(), {"platform": "tiktok", "handle": handle.strip(), "language": language, "niche": niche.strip(), "description": description.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "metrics_source": "tiktok_public_page", "subscriber_count": imported.get("subscriber_count"), "video_count": imported.get("video_count")})
|
|
1934
|
-
|
|
1945
|
+
avatar_url = _tiktok_avatar_url(imported)
|
|
1946
|
+
update_channel(channel["id"], {"platform": "tiktok", "default_prompt_master": prompt, "prompt_master": prompt, "avatar_url": avatar_url, "thumbnail_url": avatar_url})
|
|
1935
1947
|
st.session_state.pop("tiktok_channel_import", None)
|
|
1936
1948
|
st.success(f"Canal {channel['name']} cadastrado.")
|
|
1937
1949
|
st.rerun()
|
|
@@ -1952,6 +1964,17 @@ def render_tiktok_channels():
|
|
|
1952
1964
|
st.success(f"{created} canal(is) TikTok cadastrado(s).")
|
|
1953
1965
|
st.rerun()
|
|
1954
1966
|
st.caption("A planilha usa os campos de URL, nome, handle, idioma, nicho, Prompt Master, descrição e horário.")
|
|
1967
|
+
st.divider()
|
|
1968
|
+
st.subheader(f"Canais TikTok cadastrados ({len(channels)})")
|
|
1969
|
+
if channels:
|
|
1970
|
+
st.dataframe(
|
|
1971
|
+
[{k: c.get(v, "") for k, v in {"URL canal": "url", "Nome canal": "name", "Handle canal": "handle", "Idioma": "language", "Nicho": "niche", "Prompt Master": "default_prompt_master", "Proporção": "video_aspect_ratio", "Activo": "active", "Descrição": "description"}.items()} for c in channels],
|
|
1972
|
+
use_container_width=True,
|
|
1973
|
+
hide_index=True,
|
|
1974
|
+
height=360,
|
|
1975
|
+
)
|
|
1976
|
+
else:
|
|
1977
|
+
st.info("Ainda não existem canais TikTok cadastrados.")
|
|
1955
1978
|
with manual_tab:
|
|
1956
1979
|
with st.form("tiktok_channel_manual_form"):
|
|
1957
1980
|
url = st.text_input("URL canal", placeholder="https://www.tiktok.com/@conta", key="tiktok_manual_channel_url")
|
|
@@ -1967,14 +1990,6 @@ def render_tiktok_channels():
|
|
|
1967
1990
|
update_channel(channel["id"], {"platform": "tiktok"})
|
|
1968
1991
|
st.success("Canal TikTok cadastrado.")
|
|
1969
1992
|
st.rerun()
|
|
1970
|
-
st.divider()
|
|
1971
|
-
st.subheader(f"Canais TikTok cadastrados ({len(channels)})")
|
|
1972
|
-
if channels:
|
|
1973
|
-
st.dataframe([{k: c.get(v, "") for k, v in {"URL canal": "url", "Nome canal": "name", "Handle canal": "handle", "Idioma": "language", "Nicho": "niche", "Prompt Master": "default_prompt_master", "Proporção": "video_aspect_ratio", "Activo": "active", "Descrição": "description"}.items()} for c in channels], use_container_width=True, hide_index=True, height=360)
|
|
1974
|
-
else:
|
|
1975
|
-
st.info("Ainda não existem canais TikTok cadastrados.")
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
1993
|
def render_channels():
|
|
1979
1994
|
st.title("Canais Youtube")
|
|
1980
1995
|
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
@@ -4606,7 +4621,7 @@ def render_tiktok_automation():
|
|
|
4606
4621
|
with st.container(border=True):
|
|
4607
4622
|
header_cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
|
|
4608
4623
|
with header_cols[0]:
|
|
4609
|
-
profile_image =
|
|
4624
|
+
profile_image = _tiktok_avatar_url(channel)
|
|
4610
4625
|
if profile_image:
|
|
4611
4626
|
st.image(profile_image, width=48)
|
|
4612
4627
|
else:
|
|
@@ -4638,7 +4653,8 @@ def render_tiktok_automation():
|
|
|
4638
4653
|
if not valid_hhmm(schedule_time):
|
|
4639
4654
|
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
4640
4655
|
else:
|
|
4641
|
-
|
|
4656
|
+
avatar_url = _tiktok_avatar_url(channel)
|
|
4657
|
+
update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "platform": "tiktok", "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait", "avatar_url": avatar_url, "thumbnail_url": avatar_url})
|
|
4642
4658
|
st.success("Automação TikTok guardada.")
|
|
4643
4659
|
st.rerun()
|
|
4644
4660
|
|
|
@@ -7590,6 +7606,94 @@ def render_metadata_cleaner():
|
|
|
7590
7606
|
st.download_button("Descarregar", data=output.read_bytes(), file_name=output.name, mime="video/*", key=f"metadata_history_{record.get('id')}")
|
|
7591
7607
|
|
|
7592
7608
|
|
|
7609
|
+
def render_update_youtube_videos():
|
|
7610
|
+
st.title("Update Youtube Vídeos")
|
|
7611
|
+
st.caption("Actualize título, descrição e thumbnail de vídeos publicados sem alterar o ficheiro de vídeo.")
|
|
7612
|
+
settings = read_json("settings.json", {})
|
|
7613
|
+
channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict) and channel.get("platform", "youtube") != "tiktok" and channel.get("active", True)]
|
|
7614
|
+
if not channels:
|
|
7615
|
+
st.info("Cadastre pelo menos um canal YouTube activo antes de actualizar vídeos.")
|
|
7616
|
+
return
|
|
7617
|
+
channel = st.selectbox("Canal YouTube", channels, format_func=lambda item: item.get("name") or item.get("url") or "Canal sem nome", key="update_youtube_channel")
|
|
7618
|
+
updater = YouTubeVideoUpdater(settings, STORAGE)
|
|
7619
|
+
refresh_key = f"update_youtube_loaded_{channel.get('id', '')}"
|
|
7620
|
+
if st.button("Carregar vídeos do canal", type="primary", use_container_width=True, key="update_youtube_load") or refresh_key not in st.session_state:
|
|
7621
|
+
with st.spinner("A carregar vídeos publicados…"):
|
|
7622
|
+
result = updater.list_videos(channel, max_results=50)
|
|
7623
|
+
st.session_state[refresh_key] = result.data if result.ok else {"error": result.message, "videos": []}
|
|
7624
|
+
loaded = st.session_state.get(refresh_key, {})
|
|
7625
|
+
if loaded.get("error"):
|
|
7626
|
+
st.error(loaded["error"])
|
|
7627
|
+
return
|
|
7628
|
+
videos = loaded.get("videos", [])
|
|
7629
|
+
st.caption(f"{len(videos)} vídeo(s) carregado(s). Operação: `{YOUTUBE_UPDATE_VIDEO}`")
|
|
7630
|
+
if not videos:
|
|
7631
|
+
st.info("Não foram encontrados vídeos publicados para este canal.")
|
|
7632
|
+
return
|
|
7633
|
+
tasks = load_video_tasks_for_catalog()
|
|
7634
|
+
blueprint = blueprint_for_channel(channel)
|
|
7635
|
+
for video in videos:
|
|
7636
|
+
video_id = str(video.get("id") or "")
|
|
7637
|
+
card_key = f"update_youtube_{video_id}"
|
|
7638
|
+
with st.container(border=True):
|
|
7639
|
+
top = st.columns([1.2, 3.8, 1.2])
|
|
7640
|
+
with top[0]:
|
|
7641
|
+
if video.get("thumbnail_url"):
|
|
7642
|
+
st.image(video["thumbnail_url"], use_container_width=True)
|
|
7643
|
+
with top[1]:
|
|
7644
|
+
st.markdown(f"**{video.get('title') or 'Sem título'}**")
|
|
7645
|
+
st.caption(f"{video.get('published_at') or 'Data indisponível'} · {video.get('privacy_status') or 'estado desconhecido'} · `{video_id}`")
|
|
7646
|
+
st.markdown(f"[Abrir no YouTube]({video.get('url')})")
|
|
7647
|
+
with top[2]:
|
|
7648
|
+
st.caption("Alteração")
|
|
7649
|
+
st.write("Metadados")
|
|
7650
|
+
st.write("Sem alteração do vídeo")
|
|
7651
|
+
title_key, desc_key = f"{card_key}_title", f"{card_key}_description"
|
|
7652
|
+
title = st.text_input("Título", value=st.session_state.get(title_key, video.get("title", "")), key=title_key, max_chars=100)
|
|
7653
|
+
description = st.text_area("Descrição", value=st.session_state.get(desc_key, video.get("description", "")), key=desc_key, height=150)
|
|
7654
|
+
ai_cols = st.columns(3)
|
|
7655
|
+
script = next((str(task.get("script") or task.get("video_script") or "") for task in tasks if str(task.get("youtube_video_id") or task.get("video_id") or "") == video_id), "")
|
|
7656
|
+
with ai_cols[0]:
|
|
7657
|
+
if st.button("Gerar título", key=f"{card_key}_ai_title", use_container_width=True):
|
|
7658
|
+
try:
|
|
7659
|
+
generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="title")
|
|
7660
|
+
st.session_state[title_key] = generated["title"]
|
|
7661
|
+
st.rerun()
|
|
7662
|
+
except CreativeGenerationError as exc:
|
|
7663
|
+
st.error(str(exc))
|
|
7664
|
+
with ai_cols[1]:
|
|
7665
|
+
if st.button("Gerar descrição", key=f"{card_key}_ai_description", use_container_width=True):
|
|
7666
|
+
try:
|
|
7667
|
+
generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="description")
|
|
7668
|
+
st.session_state[desc_key] = generated["description"]
|
|
7669
|
+
st.rerun()
|
|
7670
|
+
except CreativeGenerationError as exc:
|
|
7671
|
+
st.error(str(exc))
|
|
7672
|
+
with ai_cols[2]:
|
|
7673
|
+
if st.button("Gerar título e descrição", key=f"{card_key}_ai_both", use_container_width=True):
|
|
7674
|
+
try:
|
|
7675
|
+
generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="both")
|
|
7676
|
+
st.session_state[title_key] = generated.get("title", title)
|
|
7677
|
+
st.session_state[desc_key] = generated.get("description", description)
|
|
7678
|
+
st.rerun()
|
|
7679
|
+
except CreativeGenerationError as exc:
|
|
7680
|
+
st.error(str(exc))
|
|
7681
|
+
thumbnail = st.file_uploader("Trocar thumbnail (opcional)", type=["jpg", "jpeg", "png"], key=f"{card_key}_thumbnail")
|
|
7682
|
+
if st.button("Actualizar no YouTube", type="primary", key=f"{card_key}_save", use_container_width=True):
|
|
7683
|
+
thumbnail_path = None
|
|
7684
|
+
if thumbnail is not None:
|
|
7685
|
+
thumbnail_path = STORAGE / "youtube_update" / f"{video_id}_{thumbnail.name}"
|
|
7686
|
+
thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
|
|
7687
|
+
thumbnail_path.write_bytes(thumbnail.getvalue())
|
|
7688
|
+
result = updater.update_video(video, title=title, description=description, thumbnail_path=thumbnail_path)
|
|
7689
|
+
if result.ok:
|
|
7690
|
+
st.success(f"{result.message} Campos: {', '.join(result.data.get('changed') or []) or 'nenhum'}.")
|
|
7691
|
+
st.session_state.pop(refresh_key, None)
|
|
7692
|
+
st.rerun()
|
|
7693
|
+
else:
|
|
7694
|
+
st.error(result.message)
|
|
7695
|
+
|
|
7696
|
+
|
|
7593
7697
|
def render_pipeline():
|
|
7594
7698
|
st.title("Pipeline Vídeos")
|
|
7595
7699
|
st.caption("Estado das filas locais e dependências da cascata")
|
|
@@ -7612,6 +7716,7 @@ def main():
|
|
|
7612
7716
|
("Roteiros", ":material/article:", "Roteiros"),
|
|
7613
7717
|
("Thumbnails", ":material/image:", "Thumbnails"),
|
|
7614
7718
|
("Upload", ":material/cloud_upload:", "Upload"),
|
|
7719
|
+
("Update Youtube Vídeos", ":material/edit_note:", "Update Youtube Vídeos"),
|
|
7615
7720
|
]
|
|
7616
7721
|
channel_profile_items = [
|
|
7617
7722
|
("Canais YouTube", ":material/ondemand_video:", "Canais YouTube"),
|
|
@@ -7699,7 +7804,7 @@ def main():
|
|
|
7699
7804
|
nav_paths = {
|
|
7700
7805
|
"Início": "/inicio", "Automação": "/automacao", "Automação Youtube": "/automacao/youtube", "Automação Tiktok": "/automacao/tiktok",
|
|
7701
7806
|
"Niche Finder": "/niche-finder", "Niche Finder Kaggle": "/niche-finder/kaggle", "Niche Finder Apify": "/niche-finder/apify",
|
|
7702
|
-
"Pipeline Vídeos": "/pipeline-videos", "Criação de Vídeos": "/pipeline-videos/criacao", "Criação de Shorts": "/pipeline-videos/shorts", "Backlog Vídeos": "/pipeline-videos/backlog", "Roteiros": "/pipeline-videos/roteiros", "Thumbnails": "/pipeline-videos/thumbnails", "Upload": "/pipeline-videos/upload",
|
|
7807
|
+
"Pipeline Vídeos": "/pipeline-videos", "Criação de Vídeos": "/pipeline-videos/criacao", "Criação de Shorts": "/pipeline-videos/shorts", "Backlog Vídeos": "/pipeline-videos/backlog", "Roteiros": "/pipeline-videos/roteiros", "Thumbnails": "/pipeline-videos/thumbnails", "Upload": "/pipeline-videos/upload", "Update Youtube Vídeos": "/pipeline-videos/update-youtube",
|
|
7703
7808
|
"Pipeline Música": "/pipeline-musica", "Criação de Músicas": "/pipeline-musica/criacao", "Music Backlog": "/pipeline-musica/backlog", "Vozes Personalizadas": "/pipeline-musica/vozes-personalizadas", "Upload Música": "/pipeline-musica/upload",
|
|
7704
7809
|
"Canais/Perfis (Vídeos)": "/canais-perfis-videos", "Canais YouTube": "/canais-perfis-videos/canais-youtube", "Canais Tiktok": "/canais-perfis-videos/canais-tiktok", "Blueprints Youtube": "/canais-perfis-videos/blueprints-youtube", "Thumbnail Blueprints": "/canais-perfis-videos/thumbnail-blueprints", "Brandings Youtube": "/canais-perfis-videos/brandings-youtube", "Contas TikTok": "/canais-perfis-videos/contas-tiktok", "Prompt Masters": "/canais-perfis-videos/prompt-masters", "Facebook Pages": "/canais-perfis-videos/facebook-pages",
|
|
7705
7810
|
"AI Influencers": "/ai-influencers", "Personagens": "/ai-influencers/personagens", "Geração de Conteúdo IA": "/ai-influencers/geracao-conteudo", "Motion Control": "/ai-influencers/motion-control", "UGC Products": "/ai-influencers/ugc-products", "Redes Sociais": "/ai-influencers/redes-sociais",
|
|
@@ -7786,6 +7891,7 @@ def main():
|
|
|
7786
7891
|
"Roteiros": render_scripts,
|
|
7787
7892
|
"Thumbnails": render_thumbnails,
|
|
7788
7893
|
"Upload": render_upload,
|
|
7894
|
+
"Update Youtube Vídeos": render_update_youtube_videos,
|
|
7789
7895
|
"Blueprints Youtube": render_blueprints,
|
|
7790
7896
|
"Thumbnail Blueprints": render_thumbnail_blueprints,
|
|
7791
7897
|
"Brandings Youtube": render_youtube_brandings,
|
|
@@ -599,3 +599,44 @@ def generate_ugc_segment_prompts(settings: dict[str, Any], instructions: str) ->
|
|
|
599
599
|
except CreativeGenerationError:
|
|
600
600
|
pass
|
|
601
601
|
return _ugc_fallback_prompts(clean)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def generate_video_update_metadata(
|
|
605
|
+
settings: dict[str, Any],
|
|
606
|
+
channel: dict[str, Any],
|
|
607
|
+
video: dict[str, Any],
|
|
608
|
+
*,
|
|
609
|
+
script: str = "",
|
|
610
|
+
blueprint: dict[str, Any] | None = None,
|
|
611
|
+
mode: str = "both",
|
|
612
|
+
) -> dict[str, str]:
|
|
613
|
+
"""Generate reviewed metadata for an already-published YouTube video."""
|
|
614
|
+
context = channel_context(channel, blueprint)
|
|
615
|
+
language_instruction = _language_instruction(video.get("default_language") or channel.get("language") or context["language"])
|
|
616
|
+
fields = {"title": "title", "description": "description"} if mode == "both" else {mode: mode}
|
|
617
|
+
system = (
|
|
618
|
+
"És um editor de metadados de YouTube. Gera apenas os campos solicitados em JSON válido. "
|
|
619
|
+
f"Escreve todos os campos em {language_instruction}. Mantém o tema, o roteiro e o nicho do canal, "
|
|
620
|
+
"evita clickbait falso, alegações não verificadas e menções a IA. O título deve ser claro e ter no máximo 100 caracteres; "
|
|
621
|
+
"a descrição deve ser natural, informativa e preservar factos fornecidos no roteiro."
|
|
622
|
+
)
|
|
623
|
+
user = json.dumps({
|
|
624
|
+
"requested_fields": list(fields.values()),
|
|
625
|
+
"channel": context,
|
|
626
|
+
"video": {"id": video.get("id"), "title": video.get("title", ""), "description": video.get("description", ""), "language": video.get("default_language", "")},
|
|
627
|
+
"script": str(script or "")[:12000],
|
|
628
|
+
"requirements": {"title_max_characters": 100, "description_max_characters": 1800},
|
|
629
|
+
}, ensure_ascii=False)
|
|
630
|
+
result = _chat_json(settings, system, user)
|
|
631
|
+
output: dict[str, str] = {}
|
|
632
|
+
if "title" in fields:
|
|
633
|
+
title = str(result.get("title") or "").strip()[:100]
|
|
634
|
+
if not title:
|
|
635
|
+
raise CreativeGenerationError("O provider não devolveu um título válido.")
|
|
636
|
+
output["title"] = title
|
|
637
|
+
if "description" in fields:
|
|
638
|
+
description = str(result.get("description") or "").strip()[:1800]
|
|
639
|
+
if not description:
|
|
640
|
+
raise CreativeGenerationError("O provider não devolveu uma descrição válida.")
|
|
641
|
+
output["description"] = description
|
|
642
|
+
return output
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from integrations.platforms import IntegrationResult
|
|
8
|
+
from integrations.youtube_upload import AGENT_SCOPES, YouTubeAutomationAgentUploader, _path_value
|
|
9
|
+
|
|
10
|
+
YOUTUBE_UPDATE_VIDEO = "YOUTUBE_UPDATE_VIDEO"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _channel_id(channel: dict[str, Any]) -> str:
|
|
14
|
+
value = str(channel.get("youtube_channel_id") or channel.get("youtube_id") or "").strip()
|
|
15
|
+
if value.startswith("UC"):
|
|
16
|
+
return value
|
|
17
|
+
match = re.search(r"(?:/channel/)(UC[A-Za-z0-9_-]+)", str(channel.get("url") or ""))
|
|
18
|
+
return match.group(1) if match else ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class YouTubeVideoUpdater(YouTubeAutomationAgentUploader):
|
|
22
|
+
"""Official OAuth adapter for metadata-only YouTube updates."""
|
|
23
|
+
|
|
24
|
+
def list_videos(self, channel: dict[str, Any], *, page_token: str = "", max_results: int = 25) -> IntegrationResult:
|
|
25
|
+
channel_id = _channel_id(channel)
|
|
26
|
+
if not channel_id:
|
|
27
|
+
return IntegrationResult(False, "Este canal não tem um YouTube channel ID. Actualize o cadastro do canal antes de listar vídeos.", {"operation": YOUTUBE_UPDATE_VIDEO, "videos": []})
|
|
28
|
+
try:
|
|
29
|
+
credentials = self._load_credentials()
|
|
30
|
+
if credentials is None:
|
|
31
|
+
return IntegrationResult(False, "Autorize primeiro a conta YouTube para listar vídeos.", {"operation": YOUTUBE_UPDATE_VIDEO, "videos": []})
|
|
32
|
+
youtube = self._build_client(credentials)
|
|
33
|
+
channel_response = youtube.channels().list(part="contentDetails", id=channel_id).execute()
|
|
34
|
+
items = channel_response.get("items") or []
|
|
35
|
+
if not items:
|
|
36
|
+
return IntegrationResult(False, "O canal YouTube não foi encontrado para esta conta autorizada.", {"operation": YOUTUBE_UPDATE_VIDEO, "videos": []})
|
|
37
|
+
uploads_id = ((items[0].get("contentDetails") or {}).get("relatedPlaylists") or {}).get("uploads")
|
|
38
|
+
if not uploads_id:
|
|
39
|
+
return IntegrationResult(False, "Não foi possível localizar a playlist de uploads do canal.", {"operation": YOUTUBE_UPDATE_VIDEO, "videos": []})
|
|
40
|
+
ids: list[str] = []
|
|
41
|
+
next_token = page_token or ""
|
|
42
|
+
while True:
|
|
43
|
+
playlist = youtube.playlistItems().list(part="snippet,contentDetails", playlistId=uploads_id, maxResults=max(1, min(50, int(max_results))), pageToken=next_token).execute()
|
|
44
|
+
ids.extend(str((item.get("contentDetails") or {}).get("videoId") or "") for item in playlist.get("items", []))
|
|
45
|
+
next_token = str(playlist.get("nextPageToken") or "")
|
|
46
|
+
if not next_token:
|
|
47
|
+
break
|
|
48
|
+
ids = [item for item in ids if item]
|
|
49
|
+
if not ids:
|
|
50
|
+
return IntegrationResult(True, "Nenhum vídeo encontrado neste canal.", {"operation": YOUTUBE_UPDATE_VIDEO, "videos": [], "next_page_token": ""})
|
|
51
|
+
details = youtube.videos().list(part="snippet,status,contentDetails", id=",".join(ids)).execute()
|
|
52
|
+
videos = []
|
|
53
|
+
for item in details.get("items", []):
|
|
54
|
+
snippet = item.get("snippet") or {}
|
|
55
|
+
videos.append({
|
|
56
|
+
"id": item.get("id", ""),
|
|
57
|
+
"title": snippet.get("title", ""),
|
|
58
|
+
"description": snippet.get("description", ""),
|
|
59
|
+
"category_id": snippet.get("categoryId", "22"),
|
|
60
|
+
"default_language": snippet.get("defaultLanguage") or snippet.get("defaultAudioLanguage") or channel.get("language") or "pt",
|
|
61
|
+
"published_at": snippet.get("publishedAt", ""),
|
|
62
|
+
"channel_id": snippet.get("channelId", channel_id),
|
|
63
|
+
"thumbnail_url": ((snippet.get("thumbnails") or {}).get("high") or (snippet.get("thumbnails") or {}).get("default") or {}).get("url", ""),
|
|
64
|
+
"privacy_status": (item.get("status") or {}).get("privacyStatus", ""),
|
|
65
|
+
"url": f"https://www.youtube.com/watch?v={item.get('id', '')}",
|
|
66
|
+
})
|
|
67
|
+
return IntegrationResult(True, f"{len(videos)} vídeo(s) carregado(s).", {"operation": YOUTUBE_UPDATE_VIDEO, "videos": videos, "next_page_token": ""})
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
return IntegrationResult(False, f"Falha ao listar vídeos YouTube: {exc}", {"operation": YOUTUBE_UPDATE_VIDEO, "videos": []})
|
|
70
|
+
|
|
71
|
+
def update_video(self, video: dict[str, Any], *, title: str, description: str, thumbnail_path: str | Path | None = None) -> IntegrationResult:
|
|
72
|
+
video_id = str(video.get("id") or "").strip()
|
|
73
|
+
if not video_id:
|
|
74
|
+
return IntegrationResult(False, "Vídeo sem videoId; a actualização foi recusada.", {"operation": YOUTUBE_UPDATE_VIDEO})
|
|
75
|
+
title = str(title or "").strip()
|
|
76
|
+
description = str(description or "")
|
|
77
|
+
if not title:
|
|
78
|
+
return IntegrationResult(False, "O título não pode ficar vazio.", {"operation": YOUTUBE_UPDATE_VIDEO, "video_id": video_id})
|
|
79
|
+
changed: list[str] = []
|
|
80
|
+
try:
|
|
81
|
+
credentials = self._load_credentials()
|
|
82
|
+
if credentials is None:
|
|
83
|
+
return IntegrationResult(False, "Autorize primeiro a conta YouTube.", {"operation": YOUTUBE_UPDATE_VIDEO, "video_id": video_id})
|
|
84
|
+
youtube = self._build_client(credentials)
|
|
85
|
+
current_category = str(video.get("category_id") or "22")
|
|
86
|
+
body = {"id": video_id, "snippet": {"title": title[:100], "description": description, "categoryId": current_category}}
|
|
87
|
+
if title != str(video.get("title") or "") or description != str(video.get("description") or ""):
|
|
88
|
+
youtube.videos().update(part="snippet", body=body).execute()
|
|
89
|
+
if title != str(video.get("title") or ""):
|
|
90
|
+
changed.append("title")
|
|
91
|
+
if description != str(video.get("description") or ""):
|
|
92
|
+
changed.append("description")
|
|
93
|
+
thumbnail = _path_value(thumbnail_path)
|
|
94
|
+
if thumbnail:
|
|
95
|
+
if not thumbnail.exists() or not thumbnail.is_file():
|
|
96
|
+
return IntegrationResult(False, "A thumbnail seleccionada não existe.", {"operation": YOUTUBE_UPDATE_VIDEO, "video_id": video_id, "changed": changed})
|
|
97
|
+
with thumbnail.open("rb") as handle:
|
|
98
|
+
youtube.thumbnails().set(videoId=video_id, media_body=handle).execute()
|
|
99
|
+
changed.append("thumbnail")
|
|
100
|
+
return IntegrationResult(True, "Vídeo actualizado sem alterar o ficheiro de vídeo.", {"operation": YOUTUBE_UPDATE_VIDEO, "video_id": video_id, "changed": changed, "url": f"https://www.youtube.com/watch?v={video_id}"})
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
return IntegrationResult(False, f"Falha ao actualizar o vídeo YouTube: {exc}", {"operation": YOUTUBE_UPDATE_VIDEO, "video_id": video_id, "changed": changed})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def update_youtube_video(settings: dict[str, Any], channel: dict[str, Any], video: dict[str, Any], *, title: str, description: str, thumbnail_path: str | Path | None = None) -> IntegrationResult:
|
|
106
|
+
updater = YouTubeVideoUpdater(settings, Path(__file__).resolve().parents[1] / "storage")
|
|
107
|
+
return updater.update_video(video, title=title, description=description, thumbnail_path=thumbnail_path)
|
package/package.json
CHANGED