@danhachuel/thunderbolt 0.6.65 → 0.6.67

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
@@ -116,7 +116,7 @@ from hermes_ui.update_manager import check_version, restart_current_process, upd
116
116
 
117
117
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
118
118
  from hermes_ui.script_generation import generate_script_document
119
- from hermes_ui.video_length import DEFAULT_AVERAGE_VIDEO_TIME, channel_video_length, length_generation_settings, words_from_channel_time
119
+ from hermes_ui.video_length import DEFAULT_AVERAGE_VIDEO_TIME, channel_video_length, channel_video_time_value, length_generation_settings, words_from_channel_time
120
120
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
121
121
  from hermes_ui.elevenlabs_voices import ElevenLabsVoicesError, cached_personal_voices, fetch_personal_voices, personal_voice_options
122
122
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
@@ -2374,10 +2374,10 @@ def render_tiktok_channels():
2374
2374
  prompt_file = TIKTOK_PROMPT_MASTERS / str(channel.get("default_prompt_master") or channel.get("prompt_master") or "")
2375
2375
  prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
2376
2376
  calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
2377
- average_video_time = st.text_input("Tempo Medio de Video (HH:MM)", value=str(channel.get("average_video_time") or DEFAULT_AVERAGE_VIDEO_TIME), key=f"tiktok_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
2377
+ average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"tiktok_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
2378
2378
  if st.button("Guardar tempo", key=f"save_tiktok_channel_average_video_time_{channel_id}", use_container_width=True):
2379
2379
  if not valid_hhmm(average_video_time):
2380
- st.error("Use o formato HH:MM, por exemplo 00:02.")
2380
+ st.error("Use o formato MM:SS, por exemplo 12:00.")
2381
2381
  else:
2382
2382
  update_channel(channel_id, {"average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME, "average_video_word_count": words_from_channel_time(average_video_time)})
2383
2383
  st.success("Tempo médio do canal TikTok guardado.")
@@ -3073,10 +3073,10 @@ def render_channels():
3073
3073
  st.metric("Visualizações", _format_channel_count(channel.get("view_count")))
3074
3074
  with header_cols[5]:
3075
3075
  calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
3076
- average_video_time = st.text_input("Tempo Medio de Video (HH:MM)", value=str(channel.get("average_video_time") or DEFAULT_AVERAGE_VIDEO_TIME), key=f"youtube_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
3076
+ average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"youtube_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
3077
3077
  if st.button("Guardar tempo", key=f"save_youtube_channel_average_video_time_{channel_id}", use_container_width=True):
3078
3078
  if not valid_hhmm(average_video_time):
3079
- st.error("Use o formato HH:MM, por exemplo 00:02.")
3079
+ st.error("Use o formato MM:SS, por exemplo 12:00.")
3080
3080
  else:
3081
3081
  update_channel(channel_id, {"average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME, "average_video_word_count": words_from_channel_time(average_video_time)})
3082
3082
  st.success("Tempo médio do canal YouTube guardado.")
@@ -5465,7 +5465,7 @@ def render_tiktok_automation():
5465
5465
  prompt_file = TIKTOK_PROMPT_MASTERS / str(channel.get("default_prompt_master") or channel.get("prompt_master") or "")
5466
5466
  prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
5467
5467
  calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
5468
- average_video_time = st.text_input("Tempo Medio de Video (HH:MM)", value=str(channel.get("average_video_time") or DEFAULT_AVERAGE_VIDEO_TIME), key=f"tiktok_average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}.")
5468
+ average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"tiktok_average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}.")
5469
5469
  default_cols = st.columns([1.0, 1.0, 1.35, 1.45, 1.55, 1.0], gap="small")
5470
5470
  with default_cols[0]:
5471
5471
  st.markdown("**Idioma do roteiro**")
@@ -5489,7 +5489,7 @@ def render_tiktok_automation():
5489
5489
  if not valid_hhmm(schedule_time):
5490
5490
  st.error("Use o formato HH:MM, por exemplo 08:30.")
5491
5491
  elif not valid_hhmm(average_video_time):
5492
- st.error("O Tempo Medio de Video deve estar no formato HH:MM, por exemplo 00:02.")
5492
+ st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
5493
5493
  else:
5494
5494
  avatar_url = _tiktok_avatar_url(channel)
5495
5495
  update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip(), "average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME, "average_video_word_count": words_from_channel_time(average_video_time), "default_prompt_master": prompt, "prompt_master": prompt, "platform": "tiktok", "format": automation_format, "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait", "avatar_url": avatar_url, "thumbnail_url": avatar_url})
@@ -5653,7 +5653,7 @@ def render_automation():
5653
5653
  schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
5654
5654
  with header_cols[7]:
5655
5655
  calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
5656
- average_video_time = st.text_input("Tempo Medio de Video (HH:MM)", value=str(channel.get("average_video_time") or DEFAULT_AVERAGE_VIDEO_TIME), key=f"average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}.")
5656
+ average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}.")
5657
5657
 
5658
5658
  control_cols = st.columns([1.8, 1.8, 1.35, 1.2], gap="small")
5659
5659
  with control_cols[0]:
@@ -5680,7 +5680,7 @@ def render_automation():
5680
5680
  if not valid_hhmm(schedule_time):
5681
5681
  st.error("Use o formato HH:MM, por exemplo 08:30.")
5682
5682
  elif not valid_hhmm(average_video_time):
5683
- st.error("O Tempo Medio de Video deve estar no formato HH:MM, por exemplo 00:02.")
5683
+ st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
5684
5684
  else:
5685
5685
  update_channel(channel_id, {
5686
5686
  "automation_on": bool(enabled),
@@ -6,32 +6,43 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  WORDS_PER_MINUTE = 150
9
- DEFAULT_AVERAGE_VIDEO_TIME = "00:12"
9
+ DEFAULT_AVERAGE_VIDEO_TIME = "12:00"
10
10
  AVERAGE_VIDEO_TIME_KEY = "average_video_time"
11
11
  AVERAGE_VIDEO_WORD_COUNT_KEY = "average_video_word_count"
12
12
 
13
13
 
14
+ def channel_video_time_value(channel: dict[str, Any]) -> str:
15
+ """Return the card value, migrating only the historical twelve-minute default."""
16
+ value = str(channel.get(AVERAGE_VIDEO_TIME_KEY) or "").strip()
17
+ configured_words = str(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or "").strip()
18
+ if value == "00:12" and configured_words in {"", "0", "1800"}:
19
+ return DEFAULT_AVERAGE_VIDEO_TIME
20
+ return value or DEFAULT_AVERAGE_VIDEO_TIME
21
+
22
+
14
23
  def valid_hhmm(value: Any) -> bool:
24
+ """Keep the historical helper name while validating a MM:SS duration."""
15
25
  match = re.fullmatch(r"\s*(\d{1,3}):(\d{2})\s*", str(value or ""))
16
26
  if not match:
17
27
  return False
18
- hours, minutes = int(match.group(1)), int(match.group(2))
19
- return hours >= 0 and 0 <= minutes <= 59
28
+ minutes, seconds = int(match.group(1)), int(match.group(2))
29
+ return minutes >= 0 and 0 <= seconds <= 59
20
30
 
21
31
 
22
- def minutes_from_hhmm(value: Any) -> int:
32
+ def minutes_from_hhmm(value: Any) -> float:
33
+ """Convert a stored MM:SS duration to fractional minutes."""
23
34
  if not valid_hhmm(value):
24
35
  return 0
25
- hours, minutes = (int(part) for part in str(value).strip().split(":"))
26
- return hours * 60 + minutes
36
+ minutes, seconds = (int(part) for part in str(value).strip().split(":"))
37
+ return minutes + seconds / 60
27
38
 
28
39
 
29
40
  def hhmm_from_minutes(value: Any) -> str:
30
41
  try:
31
- total = max(0, int(value))
42
+ total_seconds = max(0, round(float(value) * 60))
32
43
  except (TypeError, ValueError):
33
- total = 0
34
- return f"{total // 60:02d}:{total % 60:02d}"
44
+ total_seconds = 0
45
+ return f"{total_seconds // 60:02d}:{total_seconds % 60:02d}"
35
46
 
36
47
 
37
48
  def _numbers(value: Any) -> list[int]:
@@ -91,23 +102,26 @@ def explicit_word_count(blueprint: Any = None, prompt_master: str = "") -> int:
91
102
 
92
103
 
93
104
  def channel_video_length(channel: dict[str, Any], blueprint: Any = None, prompt_master: str = "") -> tuple[int, str, str]:
94
- """Return (word_count, hh:mm, source), preferring channel override over defaults."""
95
- configured_time = minutes_from_hhmm(channel.get(AVERAGE_VIDEO_TIME_KEY))
105
+ """Return (word_count, mm:ss, source), preferring channel override over defaults."""
106
+ raw_configured_time = str(channel.get(AVERAGE_VIDEO_TIME_KEY) or "").strip()
96
107
  configured_words = int(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or 0) if str(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or "0").isdigit() else 0
108
+ if raw_configured_time == "00:12" and configured_words in {0, 1800}:
109
+ raw_configured_time = DEFAULT_AVERAGE_VIDEO_TIME
110
+ configured_time = minutes_from_hhmm(raw_configured_time)
97
111
  if configured_time:
98
- return configured_time * WORDS_PER_MINUTE, hhmm_from_minutes(configured_time), "canal"
112
+ return round(configured_time * WORDS_PER_MINUTE), hhmm_from_minutes(configured_time), "canal"
99
113
  if configured_words:
100
114
  return configured_words, hhmm_from_minutes(round(configured_words / WORDS_PER_MINUTE)), "canal"
101
115
  words = explicit_word_count(blueprint, prompt_master)
102
116
  if words:
103
117
  return words, hhmm_from_minutes(round(words / WORDS_PER_MINUTE)), "Blueprint/Prompt Master"
104
118
  default_minutes = minutes_from_hhmm(DEFAULT_AVERAGE_VIDEO_TIME)
105
- return default_minutes * WORDS_PER_MINUTE, DEFAULT_AVERAGE_VIDEO_TIME, "padrão do canal"
119
+ return round(default_minutes * WORDS_PER_MINUTE), DEFAULT_AVERAGE_VIDEO_TIME, "padrão do canal"
106
120
 
107
121
 
108
122
  def words_from_channel_time(value: Any) -> int:
109
123
  minutes = minutes_from_hhmm(value)
110
- return minutes * WORDS_PER_MINUTE if minutes else 0
124
+ return round(minutes * WORDS_PER_MINUTE) if minutes else 0
111
125
 
112
126
 
113
127
  def length_generation_settings(channel: dict[str, Any], blueprint: Any = None, prompt_master: str = "") -> dict[str, Any]:
@@ -124,6 +138,7 @@ __all__ = [
124
138
  "AVERAGE_VIDEO_TIME_KEY",
125
139
  "AVERAGE_VIDEO_WORD_COUNT_KEY",
126
140
  "DEFAULT_AVERAGE_VIDEO_TIME",
141
+ "channel_video_time_value",
127
142
  "WORDS_PER_MINUTE",
128
143
  "channel_video_length",
129
144
  "explicit_word_count",
@@ -585,11 +585,64 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
585
585
  }
586
586
 
587
587
 
588
+ def _fetch_posts_with_ytdlp(username: str, limit: int = 10) -> list[dict[str, Any]]:
589
+ """Fetch public Instagram post metadata with the installed yt-dlp CLI."""
590
+ import subprocess
591
+
592
+ url = f'https://www.instagram.com/{username}/'
593
+ cmd = [
594
+ 'yt-dlp', '--dump-json', '--no-download', '--flat-playlist',
595
+ '--playlist-end', str(max(1, int(limit))), url,
596
+ ]
597
+ try:
598
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=False)
599
+ if result.returncode != 0:
600
+ print(f'yt-dlp erro para @{username}: {result.stderr.strip()}')
601
+ return []
602
+ posts: list[dict[str, Any]] = []
603
+ for line in result.stdout.splitlines():
604
+ if not line.strip():
605
+ continue
606
+ try:
607
+ data = json.loads(line)
608
+ except json.JSONDecodeError:
609
+ continue
610
+ post_id = str(data.get('id') or '').strip()
611
+ post_url = str(data.get('webpage_url') or data.get('url') or '').strip()
612
+ shortcode = post_id or (post_url.rstrip('/').split('/')[-1] if post_url else '')
613
+ if not shortcode and not post_id:
614
+ continue
615
+ timestamp = data.get('timestamp') or data.get('release_timestamp') or ''
616
+ thumbnail = str(data.get('thumbnail') or '').strip()
617
+ posts.append({
618
+ 'id': post_id or shortcode,
619
+ 'shortcode': shortcode,
620
+ 'url': post_url or f'https://www.instagram.com/p/{shortcode}/',
621
+ 'image_url': thumbnail,
622
+ 'display_url': thumbnail,
623
+ 'caption': str(data.get('description') or data.get('title') or '').strip(),
624
+ 'published_at': timestamp,
625
+ 'timestamp': timestamp,
626
+ 'is_video': bool(data.get('vcodec') not in (None, 'none')),
627
+ })
628
+ posts = posts[:max(1, int(limit))]
629
+ print(f'yt-dlp sucesso para @{username}: {len(posts)} posts')
630
+ return posts
631
+ except Exception as exc:
632
+ print(f'yt-dlp exceção para @{username}: {exc}')
633
+ return []
634
+
635
+
588
636
  def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
589
637
  try:
590
638
  reference = normalize_instagram_reference(source)
591
639
  except ValueError as exc:
592
640
  return IntegrationResult(False, str(exc), {})
641
+ if platform.system() == 'Windows':
642
+ ytdlp_posts = _fetch_posts_with_ytdlp(reference['username'], limit)
643
+ print(f'yt-dlp posts retornados para @{reference["username"]}: {len(ytdlp_posts)}')
644
+ if ytdlp_posts:
645
+ return IntegrationResult(True, f'{len(ytdlp_posts)} posts obtidos via yt-dlp', reference | {'posts': ytdlp_posts})
593
646
  api_user = _fetch_web_profile_user(reference['username'])
594
647
  if api_user:
595
648
  api_posts: list[dict[str, Any]] = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.65",
3
+ "version": "0.6.67",
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",