@danhachuel/thunderbolt 0.7.14 → 0.7.16

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
@@ -2419,7 +2419,7 @@ def render_tiktok_channels():
2419
2419
  for channel in visible_tiktok_channels:
2420
2420
  channel_id = str(channel.get("id") or "")
2421
2421
  with st.container(border=True):
2422
- card_cols = st.columns([0.7, 3.05, 1.05, 1.05, 1.25, 1.35], gap="small")
2422
+ card_cols = st.columns([0.7, 2.55, 1.15, 1.05, 1.05, 1.25, 1.35], gap="small")
2423
2423
  with card_cols[0]:
2424
2424
  avatar_url = _tiktok_avatar_url(channel)
2425
2425
  if avatar_url:
@@ -2432,12 +2432,16 @@ def render_tiktok_channels():
2432
2432
  st.caption(channel.get("description") or "Perfil carregado da página pública do TikTok.")
2433
2433
  st.caption(f"Nicho: {channel.get('niche') or 'não configurado'}")
2434
2434
  with card_cols[2]:
2435
- st.metric("Seguidores", format_metric_number(channel.get("subscriber_count")))
2435
+ tiktok_url = _tiktok_channel_url(channel)
2436
+ if tiktok_url:
2437
+ st.link_button("Abrir canal", tiktok_url, type="primary", width="content")
2436
2438
  with card_cols[3]:
2437
- st.metric("Curtidas", format_metric_number(channel.get("likes_count")))
2439
+ st.metric("Seguidores", format_metric_number(channel.get("subscriber_count")))
2438
2440
  with card_cols[4]:
2439
- st.metric("Vídeos", format_metric_number(channel.get("video_count")))
2441
+ st.metric("Curtidas", format_metric_number(channel.get("likes_count")))
2440
2442
  with card_cols[5]:
2443
+ st.metric("Vídeos", format_metric_number(channel.get("video_count")))
2444
+ with card_cols[6]:
2441
2445
  if st.button("↻", key=f"refresh_tiktok_metrics_{channel_id}", help="Actualizar Seguidores, Vídeos e Curtidas", width="stretch"):
2442
2446
  with st.spinner("A actualizar métricas TikTok…"):
2443
2447
  refreshed, message = _refresh_tiktok_channel_metrics(channel)
@@ -2651,20 +2655,37 @@ def _refresh_youtube_channel_metrics(channel: dict[str, Any], youtube: YouTubeAd
2651
2655
 
2652
2656
 
2653
2657
  def _refresh_tiktok_channel_metrics(channel: dict[str, Any]) -> tuple[bool, str]:
2654
- result = fetch_public_tiktok_profile(str(channel.get("url") or channel.get("handle") or ""))
2658
+ channel_ref = str(channel.get("url") or channel.get("handle") or "").strip()
2659
+ if not channel_ref:
2660
+ return False, "Este canal não tem URL ou handle TikTok para actualizar as métricas."
2661
+ try:
2662
+ result = fetch_public_tiktok_profile(channel_ref)
2663
+ except Exception as exc:
2664
+ return False, f"Não foi possível actualizar as métricas TikTok ({type(exc).__name__}). Tente novamente."
2655
2665
  if not result.ok or not isinstance(result.data, dict):
2656
2666
  return False, result.message
2657
2667
  data = result.data
2658
- update_channel(str(channel["id"]), {
2659
- "subscriber_count": data.get("subscriber_count"),
2660
- "likes_count": data.get("likes_count"),
2661
- "video_count": data.get("video_count"),
2668
+ updates = {
2662
2669
  "metrics_source": data.get("metrics_source", "tiktok_public_page"),
2663
2670
  "last_public_lookup_at": data.get("last_public_lookup_at", now()),
2664
- })
2671
+ }
2672
+ for field in ("subscriber_count", "likes_count", "video_count", "view_count", "name", "handle", "url", "avatar_url", "thumbnail_url"):
2673
+ if data.get(field) is not None and data.get(field) != "":
2674
+ updates[field] = data[field]
2675
+ update_channel(str(channel["id"]), updates)
2665
2676
  return True, "Métricas TikTok actualizadas."
2666
2677
 
2667
2678
 
2679
+ def _tiktok_channel_url(channel: dict[str, Any]) -> str:
2680
+ source = str(channel.get("url") or channel.get("handle") or channel.get("username") or "").strip()
2681
+ if not source:
2682
+ return ""
2683
+ try:
2684
+ return str(normalize_tiktok_reference(source).get("url") or "").strip()
2685
+ except ValueError:
2686
+ return source if source.startswith(("http://", "https://")) else ""
2687
+
2688
+
2668
2689
  def render_channels():
2669
2690
  st.title("Canais Youtube")
2670
2691
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
@@ -5590,6 +5611,11 @@ def _render_tiktok_automation_cards():
5590
5611
  st.subheader("Vídeos cadastrados TikTok")
5591
5612
  st.caption("Esta fila mostra exclusivamente tarefas associadas a canais TikTok.")
5592
5613
  tiktok_tasks = load_automation_tasks_for_platform("tiktok")
5614
+ tiktok_channels_by_id = {
5615
+ str(channel.get("id")): channel
5616
+ for channel in read_json("channels.json", [])
5617
+ if isinstance(channel, dict) and classify_channel_platform(channel) == "tiktok"
5618
+ }
5593
5619
  if not tiktok_tasks:
5594
5620
  st.info("Ainda não existem vídeos TikTok cadastrados.")
5595
5621
  for task in tiktok_tasks:
@@ -5608,6 +5634,15 @@ def _render_tiktok_automation_cards():
5608
5634
  st.caption("Thumbnail ainda não pronta")
5609
5635
  st.write(f"**{task.get('title') or task.get('topic') or 'Vídeo TikTok'}**")
5610
5636
  st.caption(f"{task.get('channel_name') or 'Canal TikTok'} · {task_id}")
5637
+ task_channel = tiktok_channels_by_id.get(str(task.get("channel_id") or ""))
5638
+ if task_channel is None:
5639
+ task_channel = next(
5640
+ (channel for channel in tiktok_channels_by_id.values() if str(channel.get("name") or "").strip() == str(task.get("channel_name") or "").strip()),
5641
+ None,
5642
+ )
5643
+ task_channel_url = _tiktok_channel_url(task_channel or {})
5644
+ if task_channel_url:
5645
+ st.link_button("Abrir canal", task_channel_url, type="primary", width="content")
5611
5646
  thumbnail_download_col, prompt_download_col = st.columns(2, gap="small")
5612
5647
  with thumbnail_download_col:
5613
5648
  st.download_button(
@@ -450,6 +450,7 @@ _LOCK_TIMEOUT_SECONDS = 30.0
450
450
  _LOCK_POLL_SECONDS = 0.05
451
451
  _LOCK_STALE_SECONDS = 120
452
452
  _PROTECTED_STATE_FILES = {"channels.json", "tasks.json", "batches.json", "queues.json", "uploads.json"}
453
+ _REPLACE_RETRY_DELAYS_SECONDS = (0.05, 0.1, 0.2, 0.4, 0.8, 1.0, 1.0, 1.0)
453
454
 
454
455
 
455
456
  class StorageIntegrityError(RuntimeError):
@@ -533,10 +534,31 @@ def _atomic_write_unlocked(path: Path, data: Any) -> None:
533
534
  handle.write("\n")
534
535
  handle.flush()
535
536
  os.fsync(handle.fileno())
536
- os.replace(temp_name, path)
537
+ replace_error: PermissionError | None = None
538
+ for delay in (0.0, *_REPLACE_RETRY_DELAYS_SECONDS):
539
+ if delay:
540
+ time.sleep(delay)
541
+ try:
542
+ os.replace(temp_name, path)
543
+ replace_error = None
544
+ break
545
+ except PermissionError as exc:
546
+ # Windows can briefly keep the destination open while Streamlit,
547
+ # an antivirus scanner, or the previous reader releases it.
548
+ # Keep the atomic replace, but give that transient handle time
549
+ # to disappear instead of failing the pipeline at its next
550
+ # progress checkpoint.
551
+ replace_error = exc
552
+ if replace_error is not None:
553
+ raise replace_error
537
554
  finally:
538
- if os.path.exists(temp_name):
539
- os.unlink(temp_name)
555
+ try:
556
+ if os.path.exists(temp_name):
557
+ os.unlink(temp_name)
558
+ except OSError:
559
+ # A scanner may also briefly hold the temporary file. The next
560
+ # write can clean it up; never hide the replace error with cleanup.
561
+ pass
540
562
 
541
563
 
542
564
  def atomic_write(path: Path, data: Any) -> None:
@@ -212,7 +212,7 @@ def fetch_public_tiktok_profile(source: str) -> IntegrationResult:
212
212
  "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
213
213
  }
214
214
  try:
215
- response = requests.get(reference["url"], headers=headers, timeout=12, allow_redirects=True)
215
+ response = requests.get(reference["url"], headers=headers, timeout=8, allow_redirects=True)
216
216
  except requests.RequestException as exc:
217
217
  return IntegrationResult(False, f"Não foi possível consultar o perfil público do TikTok: {exc}", reference)
218
218
  if response.status_code in {401, 403, 429}:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
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",