@danhachuel/thunderbolt 0.4.44 → 0.4.46

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
@@ -1931,7 +1932,7 @@ def render_tiktok_channels():
1931
1932
  description = st.text_area("Descrição", value=str(imported.get("bio") or ""), key="tiktok_import_description")
1932
1933
  if st.form_submit_button("Cadastrar canal TikTok", type="primary"):
1933
1934
  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})
1935
+ 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 ""})
1935
1936
  st.session_state.pop("tiktok_channel_import", None)
1936
1937
  st.success(f"Canal {channel['name']} cadastrado.")
1937
1938
  st.rerun()
@@ -4589,7 +4590,13 @@ def render_thumbnails():
4589
4590
 
4590
4591
  def render_tiktok_automation():
4591
4592
  st.title("Automação Tiktok")
4592
- st.caption("Fila exclusiva de Shorts TikTok em Portrait 9:16, usando Prompt Master em vez de Blueprint.")
4593
+ st.caption("Agendamento diário da geração por canal. A fila TikTok usa Prompt Master e mantém exclusivamente o formato Portrait 9:16.")
4594
+ worker_status = load_worker_status()
4595
+ local_now = datetime.now().astimezone()
4596
+ if worker_status.get("alive"):
4597
+ st.success(f"Worker activo · relógio local: {local_now.strftime('%d/%m/%Y %H:%M:%S %Z')}")
4598
+ else:
4599
+ st.warning("Worker de automação não está activo. Inicie o Thunderbolt pelo launcher (`npx.cmd --yes @danhachuel/thunderbolt`) para activar as verificações horárias.")
4593
4600
  channels = _tiktok_channel_records()
4594
4601
  prompt_ids, prompt_labels = _tiktok_prompt_options()
4595
4602
  if not channels:
@@ -4598,18 +4605,43 @@ def render_tiktok_automation():
4598
4605
  for channel in channels:
4599
4606
  channel_id = str(channel["id"])
4600
4607
  with st.container(border=True):
4601
- st.write(f"**{channel.get('name', 'Sem nome')}** · {channel.get('handle', '')}")
4602
- enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"tiktok_automation_on_{channel_id}")
4603
- schedule_time = st.text_input("Horário diário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"tiktok_automation_time_{channel_id}")
4604
- prompt = st.selectbox("Prompt Master padrão", prompt_ids, index=prompt_ids.index(channel.get("default_prompt_master", "")) if channel.get("default_prompt_master", "") in prompt_ids else 0, format_func=lambda item: prompt_labels.get(item, item), key=f"tiktok_automation_prompt_{channel_id}")
4605
- st.caption("Formato da fila: Portrait 9:16 · Pool de vídeo: TikTok")
4606
- if st.button("Guardar automação TikTok", key=f"tiktok_automation_save_{channel_id}", type="primary"):
4607
- if not valid_hhmm(schedule_time):
4608
- st.error("Use o formato HH:MM, por exemplo 08:30.")
4608
+ header_cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
4609
+ with header_cols[0]:
4610
+ profile_image = channel.get("avatar_url") or channel.get("thumbnail_url")
4611
+ if profile_image:
4612
+ st.image(profile_image, width=48)
4609
4613
  else:
4610
- 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"})
4611
- st.success("Automação TikTok guardada.")
4612
- st.rerun()
4614
+ st.markdown("### TT")
4615
+ with header_cols[1]:
4616
+ st.write(f"**{channel.get('name', 'Sem nome')}**")
4617
+ st.caption(channel.get("handle") or channel.get("url") or "sem URL")
4618
+ with header_cols[2]:
4619
+ enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"tiktok_automation_on_{channel_id}")
4620
+ with header_cols[3]:
4621
+ schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"tiktok_automation_time_{channel_id}")
4622
+ default_cols = st.columns([1.0, 1.0, 1.35, 1.45, 1.55, 1.0], gap="small")
4623
+ with default_cols[0]:
4624
+ st.markdown("**Idioma Padrão**")
4625
+ st.caption(language_label(channel.get("language") or "pt"))
4626
+ with default_cols[1]:
4627
+ st.markdown("**Nicho Padrão**")
4628
+ st.caption(channel_niche_label(channel))
4629
+ with default_cols[2]:
4630
+ st.markdown("**Formato da fila**")
4631
+ st.caption("Portrait 9:16")
4632
+ with default_cols[3]:
4633
+ prompt = st.selectbox("Prompt Master Padrão", prompt_ids, index=prompt_ids.index(channel.get("default_prompt_master", "")) if channel.get("default_prompt_master", "") in prompt_ids else 0, format_func=lambda item: prompt_labels.get(item, item), key=f"tiktok_automation_prompt_{channel_id}")
4634
+ with default_cols[4]:
4635
+ st.markdown("**Pool de vídeos**")
4636
+ st.caption("TikTok / Shorts")
4637
+ with default_cols[5]:
4638
+ if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", use_container_width=True, type="primary"):
4639
+ if not valid_hhmm(schedule_time):
4640
+ st.error("Use o formato HH:MM, por exemplo 08:30.")
4641
+ else:
4642
+ 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"})
4643
+ st.success("Automação TikTok guardada.")
4644
+ st.rerun()
4613
4645
 
4614
4646
 
4615
4647
  def render_automation():
@@ -7559,6 +7591,94 @@ def render_metadata_cleaner():
7559
7591
  st.download_button("Descarregar", data=output.read_bytes(), file_name=output.name, mime="video/*", key=f"metadata_history_{record.get('id')}")
7560
7592
 
7561
7593
 
7594
+ def render_update_youtube_videos():
7595
+ st.title("Update Youtube Vídeos")
7596
+ st.caption("Actualize título, descrição e thumbnail de vídeos publicados sem alterar o ficheiro de vídeo.")
7597
+ settings = read_json("settings.json", {})
7598
+ channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict) and channel.get("platform", "youtube") != "tiktok" and channel.get("active", True)]
7599
+ if not channels:
7600
+ st.info("Cadastre pelo menos um canal YouTube activo antes de actualizar vídeos.")
7601
+ return
7602
+ 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")
7603
+ updater = YouTubeVideoUpdater(settings, STORAGE)
7604
+ refresh_key = f"update_youtube_loaded_{channel.get('id', '')}"
7605
+ 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:
7606
+ with st.spinner("A carregar vídeos publicados…"):
7607
+ result = updater.list_videos(channel, max_results=50)
7608
+ st.session_state[refresh_key] = result.data if result.ok else {"error": result.message, "videos": []}
7609
+ loaded = st.session_state.get(refresh_key, {})
7610
+ if loaded.get("error"):
7611
+ st.error(loaded["error"])
7612
+ return
7613
+ videos = loaded.get("videos", [])
7614
+ st.caption(f"{len(videos)} vídeo(s) carregado(s). Operação: `{YOUTUBE_UPDATE_VIDEO}`")
7615
+ if not videos:
7616
+ st.info("Não foram encontrados vídeos publicados para este canal.")
7617
+ return
7618
+ tasks = load_video_tasks_for_catalog()
7619
+ blueprint = blueprint_for_channel(channel)
7620
+ for video in videos:
7621
+ video_id = str(video.get("id") or "")
7622
+ card_key = f"update_youtube_{video_id}"
7623
+ with st.container(border=True):
7624
+ top = st.columns([1.2, 3.8, 1.2])
7625
+ with top[0]:
7626
+ if video.get("thumbnail_url"):
7627
+ st.image(video["thumbnail_url"], use_container_width=True)
7628
+ with top[1]:
7629
+ st.markdown(f"**{video.get('title') or 'Sem título'}**")
7630
+ st.caption(f"{video.get('published_at') or 'Data indisponível'} · {video.get('privacy_status') or 'estado desconhecido'} · `{video_id}`")
7631
+ st.markdown(f"[Abrir no YouTube]({video.get('url')})")
7632
+ with top[2]:
7633
+ st.caption("Alteração")
7634
+ st.write("Metadados")
7635
+ st.write("Sem alteração do vídeo")
7636
+ title_key, desc_key = f"{card_key}_title", f"{card_key}_description"
7637
+ title = st.text_input("Título", value=st.session_state.get(title_key, video.get("title", "")), key=title_key, max_chars=100)
7638
+ description = st.text_area("Descrição", value=st.session_state.get(desc_key, video.get("description", "")), key=desc_key, height=150)
7639
+ ai_cols = st.columns(3)
7640
+ 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), "")
7641
+ with ai_cols[0]:
7642
+ if st.button("Gerar título", key=f"{card_key}_ai_title", use_container_width=True):
7643
+ try:
7644
+ generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="title")
7645
+ st.session_state[title_key] = generated["title"]
7646
+ st.rerun()
7647
+ except CreativeGenerationError as exc:
7648
+ st.error(str(exc))
7649
+ with ai_cols[1]:
7650
+ if st.button("Gerar descrição", key=f"{card_key}_ai_description", use_container_width=True):
7651
+ try:
7652
+ generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="description")
7653
+ st.session_state[desc_key] = generated["description"]
7654
+ st.rerun()
7655
+ except CreativeGenerationError as exc:
7656
+ st.error(str(exc))
7657
+ with ai_cols[2]:
7658
+ if st.button("Gerar título e descrição", key=f"{card_key}_ai_both", use_container_width=True):
7659
+ try:
7660
+ generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="both")
7661
+ st.session_state[title_key] = generated.get("title", title)
7662
+ st.session_state[desc_key] = generated.get("description", description)
7663
+ st.rerun()
7664
+ except CreativeGenerationError as exc:
7665
+ st.error(str(exc))
7666
+ thumbnail = st.file_uploader("Trocar thumbnail (opcional)", type=["jpg", "jpeg", "png"], key=f"{card_key}_thumbnail")
7667
+ if st.button("Actualizar no YouTube", type="primary", key=f"{card_key}_save", use_container_width=True):
7668
+ thumbnail_path = None
7669
+ if thumbnail is not None:
7670
+ thumbnail_path = STORAGE / "youtube_update" / f"{video_id}_{thumbnail.name}"
7671
+ thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
7672
+ thumbnail_path.write_bytes(thumbnail.getvalue())
7673
+ result = updater.update_video(video, title=title, description=description, thumbnail_path=thumbnail_path)
7674
+ if result.ok:
7675
+ st.success(f"{result.message} Campos: {', '.join(result.data.get('changed') or []) or 'nenhum'}.")
7676
+ st.session_state.pop(refresh_key, None)
7677
+ st.rerun()
7678
+ else:
7679
+ st.error(result.message)
7680
+
7681
+
7562
7682
  def render_pipeline():
7563
7683
  st.title("Pipeline Vídeos")
7564
7684
  st.caption("Estado das filas locais e dependências da cascata")
@@ -7581,6 +7701,7 @@ def main():
7581
7701
  ("Roteiros", ":material/article:", "Roteiros"),
7582
7702
  ("Thumbnails", ":material/image:", "Thumbnails"),
7583
7703
  ("Upload", ":material/cloud_upload:", "Upload"),
7704
+ ("Update Youtube Vídeos", ":material/edit_note:", "Update Youtube Vídeos"),
7584
7705
  ]
7585
7706
  channel_profile_items = [
7586
7707
  ("Canais YouTube", ":material/ondemand_video:", "Canais YouTube"),
@@ -7668,7 +7789,7 @@ def main():
7668
7789
  nav_paths = {
7669
7790
  "Início": "/inicio", "Automação": "/automacao", "Automação Youtube": "/automacao/youtube", "Automação Tiktok": "/automacao/tiktok",
7670
7791
  "Niche Finder": "/niche-finder", "Niche Finder Kaggle": "/niche-finder/kaggle", "Niche Finder Apify": "/niche-finder/apify",
7671
- "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",
7792
+ "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",
7672
7793
  "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",
7673
7794
  "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",
7674
7795
  "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",
@@ -7755,6 +7876,7 @@ def main():
7755
7876
  "Roteiros": render_scripts,
7756
7877
  "Thumbnails": render_thumbnails,
7757
7878
  "Upload": render_upload,
7879
+ "Update Youtube Vídeos": render_update_youtube_videos,
7758
7880
  "Blueprints Youtube": render_blueprints,
7759
7881
  "Thumbnail Blueprints": render_thumbnail_blueprints,
7760
7882
  "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
@@ -39,6 +39,7 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
39
39
  "niche": "",
40
40
  "reference_channels": [],
41
41
  "thumbnail_url": "",
42
+ "avatar_url": "",
42
43
  "subscriber_count": None,
43
44
  "video_count": None,
44
45
  "view_count": None,
@@ -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.44",
3
+ "version": "0.4.46",
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",