@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 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 = imported.get("avatar_url") or imported.get("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
- update_channel(channel["id"], {"platform": "tiktok", "default_prompt_master": prompt, "prompt_master": prompt, "avatar_url": imported.get("avatar_url") or imported.get("avatar") or "", "thumbnail_url": imported.get("avatar_url") or imported.get("avatar") or ""})
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 = channel.get("avatar_url") or channel.get("thumbnail_url")
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
- 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"})
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.45",
3
+ "version": "0.4.47",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",