@danhachuel/thunderbolt 0.6.78 → 0.6.80

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
@@ -5633,57 +5633,58 @@ def render_tiktok_automation():
5633
5633
  if not channels:
5634
5634
  st.info("Cadastre primeiro um canal TikTok.")
5635
5635
  return
5636
- for channel in channels:
5637
- channel_id = str(channel["id"])
5638
- with st.container(border=True):
5639
- header_cols = st.columns([0.55, 2.35, 1.25, 0.82, 0.92, 1.2])
5640
- with header_cols[0]:
5641
- profile_image = _tiktok_avatar_url(channel)
5642
- if profile_image:
5643
- st.image(profile_image, width=48)
5644
- else:
5645
- st.markdown("### TT")
5646
- with header_cols[1]:
5647
- st.write(f"**{channel.get('name', 'Sem nome')}**")
5648
- st.caption(channel.get("handle") or channel.get("url") or "sem URL")
5649
- with header_cols[2]:
5650
- enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"tiktok_automation_on_{channel_id}")
5651
- with header_cols[3]:
5652
- schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"tiktok_automation_time_{channel_id}")
5653
- with header_cols[4]:
5654
- prompt_file = TIKTOK_PROMPT_MASTERS / str(channel.get("default_prompt_master") or channel.get("prompt_master") or "")
5655
- prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
5656
- calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
5657
- 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}.")
5658
- default_cols = st.columns([1.0, 1.0, 1.35, 1.45, 1.55, 1.0], gap="small")
5659
- with default_cols[0]:
5660
- st.markdown("**Idioma do roteiro**")
5661
- st.caption(video_language_label(normalize_video_language(channel.get("language") or "pt")))
5662
- with default_cols[1]:
5663
- st.markdown("**Nicho Padrão**")
5664
- st.caption(channel_niche_label(channel))
5665
- with default_cols[2]:
5666
- st.markdown("**Fonte do vídeo**")
5667
- st.caption(channel_video_source_value(channel.get("style_wide")))
5668
- st.markdown("**Proporção do vídeo**")
5669
- st.caption(str(channel.get("video_aspect_ratio") or "Portrait 9:16"))
5670
- st.markdown("**Formato**")
5671
- st.caption(str(channel.get("format") or "Shorts"))
5672
- with default_cols[3]:
5673
- 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}")
5674
- with default_cols[4]:
5675
- automation_format = st.selectbox("Formato", CHANNEL_FORMAT_OPTIONS, index=CHANNEL_FORMAT_OPTIONS.index(str(channel.get("format") or "Shorts")) if str(channel.get("format") or "Shorts") in CHANNEL_FORMAT_OPTIONS else 1, key=f"tiktok_automation_format_{channel_id}")
5676
- with default_cols[5]:
5677
- if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", use_container_width=True, type="primary"):
5678
- if not valid_hhmm(schedule_time):
5679
- st.error("Use o formato HH:MM, por exemplo 08:30.")
5680
- elif not valid_hhmm(average_video_time):
5681
- st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
5636
+ with st.expander("Canais cadastrados", expanded=False):
5637
+ for channel in channels:
5638
+ channel_id = str(channel["id"])
5639
+ with st.container(border=True):
5640
+ header_cols = st.columns([0.55, 2.35, 1.25, 0.82, 0.92, 1.2])
5641
+ with header_cols[0]:
5642
+ profile_image = _tiktok_avatar_url(channel)
5643
+ if profile_image:
5644
+ st.image(profile_image, width=48)
5682
5645
  else:
5683
- avatar_url = _tiktok_avatar_url(channel)
5684
- 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})
5685
- st.success("Automação TikTok guardada.")
5686
- st.rerun()
5646
+ st.markdown("### TT")
5647
+ with header_cols[1]:
5648
+ st.write(f"**{channel.get('name', 'Sem nome')}**")
5649
+ st.caption(channel.get("handle") or channel.get("url") or "sem URL")
5650
+ with header_cols[2]:
5651
+ enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"tiktok_automation_on_{channel_id}")
5652
+ with header_cols[3]:
5653
+ schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"tiktok_automation_time_{channel_id}")
5654
+ with header_cols[4]:
5655
+ prompt_file = TIKTOK_PROMPT_MASTERS / str(channel.get("default_prompt_master") or channel.get("prompt_master") or "")
5656
+ prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
5657
+ calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
5658
+ 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}.")
5659
+ default_cols = st.columns([1.0, 1.0, 1.35, 1.45, 1.55, 1.0], gap="small")
5660
+ with default_cols[0]:
5661
+ st.markdown("**Idioma do roteiro**")
5662
+ st.caption(video_language_label(normalize_video_language(channel.get("language") or "pt")))
5663
+ with default_cols[1]:
5664
+ st.markdown("**Nicho Padrão**")
5665
+ st.caption(channel_niche_label(channel))
5666
+ with default_cols[2]:
5667
+ st.markdown("**Fonte do vídeo**")
5668
+ st.caption(channel_video_source_value(channel.get("style_wide")))
5669
+ st.markdown("**Proporção do vídeo**")
5670
+ st.caption(str(channel.get("video_aspect_ratio") or "Portrait 9:16"))
5671
+ st.markdown("**Formato**")
5672
+ st.caption(str(channel.get("format") or "Shorts"))
5673
+ with default_cols[3]:
5674
+ 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}")
5675
+ with default_cols[4]:
5676
+ automation_format = st.selectbox("Formato", CHANNEL_FORMAT_OPTIONS, index=CHANNEL_FORMAT_OPTIONS.index(str(channel.get("format") or "Shorts")) if str(channel.get("format") or "Shorts") in CHANNEL_FORMAT_OPTIONS else 1, key=f"tiktok_automation_format_{channel_id}")
5677
+ with default_cols[5]:
5678
+ if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", use_container_width=True, type="primary"):
5679
+ if not valid_hhmm(schedule_time):
5680
+ st.error("Use o formato HH:MM, por exemplo 08:30.")
5681
+ elif not valid_hhmm(average_video_time):
5682
+ st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
5683
+ else:
5684
+ avatar_url = _tiktok_avatar_url(channel)
5685
+ 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})
5686
+ st.success("Automação TikTok guardada.")
5687
+ st.rerun()
5687
5688
  _render_tiktok_automation_cards()
5688
5689
 
5689
5690
  @st.fragment(run_every=5.0)
@@ -5807,82 +5808,83 @@ def render_automation():
5807
5808
  channels = [channel for channel in read_json("channels.json", []) if is_youtube_channel_record(channel)]
5808
5809
  if not channels:
5809
5810
  st.info("Nenhum canal cadastrado para configurar.")
5810
- for channel in channels:
5811
- channel_id = channel["id"]
5812
- with st.container(border=True):
5813
- blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
5814
- automation_blueprint = current_blueprint
5815
- automation_voice = current_voice
5816
- paired_thumbnail = thumbnail_blueprint_for_blueprint(automation_blueprint)
5817
- automation_format = str(channel.get("format") or "wide")
5818
- header_cols = st.columns([0.58, 2.15, 1.4, 1.15, 1.15, 1.25, 0.82, 0.92], gap="small")
5819
- with header_cols[0]:
5820
- if channel.get("thumbnail_url"):
5821
- st.image(channel["thumbnail_url"], width=48)
5822
- else:
5823
- st.markdown("### YT")
5824
- with header_cols[1]:
5825
- st.write(f"**{channel.get('name', 'Sem nome')}**")
5826
- st.caption(channel.get("handle") or channel.get("url") or "sem URL")
5827
- with header_cols[2]:
5828
- st.markdown("**Thumbnail Blueprint**")
5829
- st.caption(str(paired_thumbnail.get("name") or "Generic_Thumbnail_Blueprint"))
5830
- with header_cols[3]:
5831
- st.markdown("**Idioma do roteiro**")
5832
- st.caption(video_language_label(normalize_video_language(channel.get("language") or "pt")))
5833
- with header_cols[4]:
5834
- st.markdown("**Nicho Padrão**")
5835
- st.caption(channel_niche_label(channel))
5836
- with header_cols[5]:
5837
- st.markdown("**Fonte do vídeo**")
5838
- st.caption(channel_video_source_value(channel.get("style_wide")))
5839
- st.markdown("**Proporção do vídeo**")
5840
- st.caption(str(channel.get("video_aspect_ratio") or "Landscape 16:9"))
5841
- with header_cols[6]:
5842
- schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
5843
- with header_cols[7]:
5844
- calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
5845
- 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}.")
5846
-
5847
- control_cols = st.columns([1.8, 1.8, 1.35, 1.2], gap="small")
5848
- with control_cols[0]:
5849
- automation_blueprint = st.selectbox(
5850
- "Blueprint Padrão",
5851
- blueprint_ids,
5852
- index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
5853
- format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
5854
- key=f"automation_blueprint_{channel_id}",
5855
- )
5856
- paired_thumbnail = thumbnail_blueprint_for_blueprint(automation_blueprint)
5857
- with control_cols[1]:
5858
- automation_voice = st.selectbox(
5859
- "Narrador/Voz Padrão",
5860
- voice_options,
5861
- index=voice_options.index(current_voice) if current_voice in voice_options else 0,
5862
- format_func=lambda item: item or "Sem voz padrão",
5863
- key=f"automation_voice_{channel_id}",
5864
- )
5865
- with control_cols[2]:
5866
- enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
5867
- with control_cols[3]:
5868
- if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True, type="primary"):
5869
- if not valid_hhmm(schedule_time):
5870
- st.error("Use o formato HH:MM, por exemplo 08:30.")
5871
- elif not valid_hhmm(average_video_time):
5872
- st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
5811
+ with st.expander("Canais cadastrados", expanded=False):
5812
+ for channel in channels:
5813
+ channel_id = channel["id"]
5814
+ with st.container(border=True):
5815
+ blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
5816
+ automation_blueprint = current_blueprint
5817
+ automation_voice = current_voice
5818
+ paired_thumbnail = thumbnail_blueprint_for_blueprint(automation_blueprint)
5819
+ automation_format = str(channel.get("format") or "wide")
5820
+ header_cols = st.columns([0.58, 2.15, 1.4, 1.15, 1.15, 1.25, 0.82, 0.92], gap="small")
5821
+ with header_cols[0]:
5822
+ if channel.get("thumbnail_url"):
5823
+ st.image(channel["thumbnail_url"], width=48)
5873
5824
  else:
5874
- update_channel(channel_id, {
5875
- "automation_on": bool(enabled),
5876
- "automation_time": schedule_time.strip(),
5877
- "average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME,
5878
- "average_video_word_count": words_from_channel_time(average_video_time),
5879
- "format": automation_format,
5880
- })
5881
- set_channel_defaults(channel_id, automation_blueprint, automation_voice)
5882
- paired_id = str(paired_thumbnail.get("id") or "Generic_Thumbnail_Blueprint")
5883
- update_channel(channel_id, {"thumbnail_blueprint_id": paired_id, "default_thumbnail_blueprint_id": paired_id})
5884
- st.success("Agendamento guardado.")
5885
- st.rerun()
5825
+ st.markdown("### YT")
5826
+ with header_cols[1]:
5827
+ st.write(f"**{channel.get('name', 'Sem nome')}**")
5828
+ st.caption(channel.get("handle") or channel.get("url") or "sem URL")
5829
+ with header_cols[2]:
5830
+ st.markdown("**Thumbnail Blueprint**")
5831
+ st.caption(str(paired_thumbnail.get("name") or "Generic_Thumbnail_Blueprint"))
5832
+ with header_cols[3]:
5833
+ st.markdown("**Idioma do roteiro**")
5834
+ st.caption(video_language_label(normalize_video_language(channel.get("language") or "pt")))
5835
+ with header_cols[4]:
5836
+ st.markdown("**Nicho Padrão**")
5837
+ st.caption(channel_niche_label(channel))
5838
+ with header_cols[5]:
5839
+ st.markdown("**Fonte do vídeo**")
5840
+ st.caption(channel_video_source_value(channel.get("style_wide")))
5841
+ st.markdown("**Proporção do vídeo**")
5842
+ st.caption(str(channel.get("video_aspect_ratio") or "Landscape 16:9"))
5843
+ with header_cols[6]:
5844
+ schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
5845
+ with header_cols[7]:
5846
+ calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
5847
+ 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}.")
5848
+
5849
+ control_cols = st.columns([1.8, 1.8, 1.35, 1.2], gap="small")
5850
+ with control_cols[0]:
5851
+ automation_blueprint = st.selectbox(
5852
+ "Blueprint Padrão",
5853
+ blueprint_ids,
5854
+ index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
5855
+ format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
5856
+ key=f"automation_blueprint_{channel_id}",
5857
+ )
5858
+ paired_thumbnail = thumbnail_blueprint_for_blueprint(automation_blueprint)
5859
+ with control_cols[1]:
5860
+ automation_voice = st.selectbox(
5861
+ "Narrador/Voz Padrão",
5862
+ voice_options,
5863
+ index=voice_options.index(current_voice) if current_voice in voice_options else 0,
5864
+ format_func=lambda item: item or "Sem voz padrão",
5865
+ key=f"automation_voice_{channel_id}",
5866
+ )
5867
+ with control_cols[2]:
5868
+ enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
5869
+ with control_cols[3]:
5870
+ if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True, type="primary"):
5871
+ if not valid_hhmm(schedule_time):
5872
+ st.error("Use o formato HH:MM, por exemplo 08:30.")
5873
+ elif not valid_hhmm(average_video_time):
5874
+ st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
5875
+ else:
5876
+ update_channel(channel_id, {
5877
+ "automation_on": bool(enabled),
5878
+ "automation_time": schedule_time.strip(),
5879
+ "average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME,
5880
+ "average_video_word_count": words_from_channel_time(average_video_time),
5881
+ "format": automation_format,
5882
+ })
5883
+ set_channel_defaults(channel_id, automation_blueprint, automation_voice)
5884
+ paired_id = str(paired_thumbnail.get("id") or "Generic_Thumbnail_Blueprint")
5885
+ update_channel(channel_id, {"thumbnail_blueprint_id": paired_id, "default_thumbnail_blueprint_id": paired_id})
5886
+ st.success("Agendamento guardado.")
5887
+ st.rerun()
5886
5888
 
5887
5889
  _render_youtube_automation_cards()
5888
5890
 
@@ -457,7 +457,7 @@ class StorageIntegrityError(RuntimeError):
457
457
 
458
458
 
459
459
  @contextmanager
460
- def _state_lock(path: Path) -> Iterator[None]:
460
+ def _state_lock(path: Path, *, read_only: bool = False) -> Iterator[None]:
461
461
  """Serialise state mutations across the UI and both local workers.
462
462
 
463
463
  A lock file is used instead of an in-memory mutex because the launcher
@@ -472,6 +472,15 @@ def _state_lock(path: Path) -> Iterator[None]:
472
472
  try:
473
473
  descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
474
474
  os.write(descriptor, f"pid={os.getpid()}\n".encode("ascii"))
475
+ except PermissionError:
476
+ if not read_only:
477
+ raise
478
+ # Windows pode manter um lock residual aberto por outro processo
479
+ # (ou por antivírus). Leituras de JSON continuam seguras porque as
480
+ # escritas usam replace atómico; não bloquear a leitura evita que
481
+ # uma página inteira falhe por causa de um lock de metadados.
482
+ yield
483
+ return
475
484
  except FileExistsError:
476
485
  try:
477
486
  if time.time() - lock_path.stat().st_mtime > _LOCK_STALE_SECONDS:
@@ -557,7 +566,7 @@ def _recover_json_unlocked(name: str, path: Path, default: Any | None) -> Any:
557
566
  def read_json(name: str, default: Any | None = None) -> Any:
558
567
  ensure_storage()
559
568
  path = STATE / name
560
- with _state_lock(path):
569
+ with _state_lock(path, read_only=True):
561
570
  try:
562
571
  data = _load_json_unlocked(path)
563
572
  except (json.JSONDecodeError, OSError):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.78",
3
+ "version": "0.6.80",
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",