@danhachuel/thunderbolt 0.4.84 → 0.4.86

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.
Files changed (2) hide show
  1. package/app/main.py +95 -12
  2. package/package.json +1 -1
package/app/main.py CHANGED
@@ -1924,6 +1924,7 @@ def render_tiktok_channels():
1924
1924
  channels = _tiktok_channel_records()
1925
1925
  import_tab, spreadsheet_tab, manual_tab = render_localized_tabs(["Importar do Tiktok", "Canais em lote Planilha", "Cadastro manual"])
1926
1926
  prompt_ids, prompt_labels = _tiktok_prompt_options()
1927
+ _, _, _, voice_options, _ = channel_default_options({})
1927
1928
  with import_tab:
1928
1929
  st.caption("A pesquisa consulta exclusivamente a página pública do TikTok. Não existe método alternativo nem utilização de API.")
1929
1930
  st.divider()
@@ -1984,9 +1985,15 @@ def render_tiktok_channels():
1984
1985
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), format_func=language_label, key="tiktok_import_language")
1985
1986
  niche = st.text_input("Nicho", key="tiktok_import_niche")
1986
1987
  prompt = st.selectbox("Prompt Master padrão", prompt_ids, format_func=lambda item: prompt_labels.get(item, item), key="tiktok_import_prompt")
1988
+ voice = st.selectbox("Narrador/Voz Padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="tiktok_import_voice")
1989
+ automation_on = st.toggle("Automação ON", value=False, key="tiktok_import_automation")
1990
+ automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="tiktok_import_automation_time")
1987
1991
  description = st.text_area("Descrição", value=str(imported.get("bio") or ""), key="tiktok_import_description")
1988
1992
  if st.form_submit_button("Cadastrar canal TikTok", type="primary"):
1989
- 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")})
1993
+ if not valid_hhmm(automation_time):
1994
+ st.error("O horário diário deve estar no formato HH:MM.")
1995
+ st.stop()
1996
+ 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, "default_voice": voice, "voice": voice, "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "automation_on": automation_on, "automation_time": automation_time.strip(), "metrics_source": "tiktok_public_page", "subscriber_count": imported.get("subscriber_count"), "video_count": imported.get("video_count")})
1990
1997
  avatar_url = _tiktok_avatar_url(imported)
1991
1998
  update_channel(channel["id"], {"platform": "tiktok", "default_prompt_master": prompt, "prompt_master": prompt, "avatar_url": avatar_url, "thumbnail_url": avatar_url})
1992
1999
  st.session_state.pop("tiktok_channel_import", None)
@@ -2009,6 +2016,7 @@ def render_tiktok_channels():
2009
2016
  st.markdown(f"**{channel.get('name') or 'Perfil TikTok'}**")
2010
2017
  st.caption(f"{channel.get('handle') or 'sem handle'} · {channel.get('url') or 'sem URL'}")
2011
2018
  st.caption(channel.get("description") or "Perfil carregado da página pública do TikTok.")
2019
+ st.caption(f"Nicho: {channel.get('niche') or 'não configurado'}")
2012
2020
  with card_cols[2]:
2013
2021
  st.metric("Seguidores", format_metric_number(channel.get("subscriber_count")))
2014
2022
  with card_cols[3]:
@@ -2027,11 +2035,20 @@ def render_tiktok_channels():
2027
2035
  if st.button("Apagar card", key=f"tiktok_import_card_delete_{channel_id}"):
2028
2036
  st.session_state[f"tiktok_delete_{channel_id}"] = True
2029
2037
  st.rerun()
2030
- st.caption("Formato: Portrait 9:16")
2038
+ st.caption("Estilo wide: Portrait 9:16")
2039
+ st.caption(f"Automação: {'ligada' if automation_on else 'desligada'} · {automation_time}")
2040
+ _, _, _, card_voice_options, card_current_voice = channel_default_options(channel)
2041
+ selected_voice = st.selectbox("Narrador/Voz Padrão", card_voice_options, index=card_voice_options.index(card_current_voice) if card_current_voice in card_voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key=f"tiktok_import_card_voice_{channel_id}")
2042
+ automation_on = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"tiktok_import_card_automation_{channel_id}")
2043
+ automation_time = st.text_input("Horário diário (HH:MM)", value=str(channel.get("automation_time") or "00:00"), key=f"tiktok_import_card_time_{channel_id}")
2031
2044
  if st.button("Guardar", key=f"tiktok_import_card_save_{channel_id}", use_container_width=True, type="primary"):
2032
- update_channel(channel_id, {"platform": "tiktok", "default_prompt_master": selected_prompt, "prompt_master": selected_prompt, "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait"})
2033
- st.success("Prompt Master do canal guardado.")
2034
- st.rerun()
2045
+ if not valid_hhmm(automation_time):
2046
+ st.error("O horário diário deve estar no formato HH:MM.")
2047
+ else:
2048
+ update_channel(channel_id, {"platform": "tiktok", "default_prompt_master": selected_prompt, "prompt_master": selected_prompt, "default_voice": selected_voice, "voice": selected_voice, "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait", "automation_on": automation_on, "automation_time": automation_time.strip()})
2049
+ st.success("Dados do canal TikTok guardados.")
2050
+ st.rerun()
2051
+ st.caption("Estilo wide: Portrait 9:16")
2035
2052
  niche_action = st.columns([1.0, 4.0])
2036
2053
  with niche_action[0]:
2037
2054
  if st.button("Editar nicho", key=f"tiktok_import_card_niche_{channel_id}"):
@@ -2062,11 +2079,18 @@ def render_tiktok_channels():
2062
2079
  edit_url = st.text_input("URL pública", value=str(channel.get("url") or ""))
2063
2080
  edit_handle = st.text_input("Handle", value=str(channel.get("handle") or ""))
2064
2081
  edit_language = st.selectbox("Idioma do canal", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index(language_code(channel.get("language") or "pt")), format_func=language_label)
2082
+ edit_voice = st.selectbox("Narrador/Voz Padrão", voice_options, index=voice_options.index(str(channel.get("default_voice") or channel.get("voice") or "")) if str(channel.get("default_voice") or channel.get("voice") or "") in voice_options else 0, format_func=lambda item: item or "Sem voz padrão")
2083
+ edit_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"tiktok_edit_automation_{channel_id}")
2084
+ edit_time = st.text_input("Horário diário (HH:MM)", value=str(channel.get("automation_time") or "00:00"))
2085
+ st.caption("Estilo wide: Portrait 9:16")
2065
2086
  edit_description = st.text_area("Descrição", value=str(channel.get("description") or ""))
2066
2087
  if st.form_submit_button("Guardar edição", type="primary"):
2067
- update_channel(channel_id, {"name": edit_name.strip(), "url": edit_url.strip(), "handle": edit_handle.strip(), "language": edit_language, "description": edit_description.strip(), "platform": "tiktok"})
2068
- st.session_state.pop(f"tiktok_edit_{channel_id}", None)
2069
- st.rerun()
2088
+ if not valid_hhmm(edit_time):
2089
+ st.error("O horário diário deve estar no formato HH:MM.")
2090
+ else:
2091
+ update_channel(channel_id, {"name": edit_name.strip(), "url": edit_url.strip(), "handle": edit_handle.strip(), "language": edit_language, "default_voice": edit_voice, "voice": edit_voice, "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "automation_on": edit_automation, "automation_time": edit_time.strip(), "description": edit_description.strip(), "platform": "tiktok"})
2092
+ st.session_state.pop(f"tiktok_edit_{channel_id}", None)
2093
+ st.rerun()
2070
2094
  with st.expander("Últimos 10 vídeos publicados", expanded=False):
2071
2095
  recent_videos = channel_videos_for(channel, limit=10)
2072
2096
  if not recent_videos:
@@ -2096,7 +2120,7 @@ def render_tiktok_channels():
2096
2120
  candidate = {"name": row.get("name", ""), "handle": row.get("handle", ""), "url": row.get("url", "")}
2097
2121
  if find_duplicate_channel(candidate, channels):
2098
2122
  continue
2099
- created_channel = create_channel(str(row.get("name") or row.get("handle") or "TikTok"), str(row.get("url") or ""), {"platform": "tiktok", "handle": row.get("handle", ""), "language": language_code(row.get("language") or "pt"), "niche": row.get("niche", ""), "description": row.get("description", ""), "default_prompt_master": row.get("prompt_master", ""), "prompt_master": row.get("prompt_master", ""), "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "automation_time": row.get("automation_time") or "00:00"})
2123
+ created_channel = create_channel(str(row.get("name") or row.get("handle") or "TikTok"), str(row.get("url") or ""), {"platform": "tiktok", "handle": row.get("handle", ""), "language": language_code(row.get("language") or "pt"), "niche": row.get("niche", ""), "description": row.get("description", ""), "default_prompt_master": row.get("prompt_master", ""), "prompt_master": row.get("prompt_master", ""), "default_voice": row.get("voice", ""), "voice": row.get("voice", ""), "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "automation_on": bool(row.get("automation_on", False)), "automation_time": row.get("automation_time") or "00:00"})
2100
2124
  update_channel(created_channel["id"], {"platform": "tiktok"})
2101
2125
  created += 1
2102
2126
  st.success(f"{created} canal(is) TikTok cadastrado(s).")
@@ -2106,7 +2130,7 @@ def render_tiktok_channels():
2106
2130
  st.subheader(f"Canais TikTok cadastrados ({len(channels)})")
2107
2131
  if channels:
2108
2132
  st.dataframe(
2109
- [{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],
2133
+ [{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", "Narrador/Voz Padrão": "default_voice", "Estilo wide": "style_wide", "Automação ligada": "automation_on", "Horário diário (HH:MM)": "automation_time", "Proporção": "video_aspect_ratio", "Activo": "active", "Descrição": "description"}.items()} for c in channels],
2110
2134
  use_container_width=True,
2111
2135
  hide_index=True,
2112
2136
  height=360,
@@ -2121,10 +2145,17 @@ def render_tiktok_channels():
2121
2145
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), format_func=language_label, key="tiktok_manual_channel_language")
2122
2146
  niche = st.text_input("Nicho", key="tiktok_manual_channel_niche")
2123
2147
  prompt = st.selectbox("Prompt Master padrão", prompt_ids, format_func=lambda item: prompt_labels.get(item, item), key="tiktok_manual_channel_prompt")
2148
+ voice = st.selectbox("Narrador/Voz Padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="tiktok_manual_channel_voice")
2149
+ automation_on = st.toggle("Automação ON", value=False, key="tiktok_manual_channel_automation")
2150
+ automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="tiktok_manual_channel_automation_time")
2151
+ st.caption("Estilo wide: Portrait 9:16")
2124
2152
  description = st.text_area("Descrição", key="tiktok_manual_channel_description")
2125
2153
  if st.form_submit_button("Guardar canal manual", type="primary"):
2126
2154
  reference = normalize_tiktok_reference(url or handle)
2127
- channel = create_channel(name.strip() or reference["username"], reference["url"], {"platform": "tiktok", "handle": reference["handle"], "language": language, "niche": niche.strip(), "description": description.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16"})
2155
+ if not valid_hhmm(automation_time):
2156
+ st.error("O horário diário deve estar no formato HH:MM.")
2157
+ st.stop()
2158
+ channel = create_channel(name.strip() or reference["username"], reference["url"], {"platform": "tiktok", "handle": reference["handle"], "language": language, "niche": niche.strip(), "description": description.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "default_voice": voice, "voice": voice, "style_wide": "portrait", "video_aspect_ratio": "Portrait 9:16", "automation_on": automation_on, "automation_time": automation_time.strip()})
2128
2159
  update_channel(channel["id"], {"platform": "tiktok"})
2129
2160
  st.success("Canal TikTok cadastrado.")
2130
2161
  st.rerun()
@@ -4435,6 +4466,28 @@ def load_video_tasks_for_catalog() -> list[dict[str, Any]]:
4435
4466
  return [task for task in saved if isinstance(task, dict) and str(task.get("id") or "").strip()]
4436
4467
 
4437
4468
 
4469
+ def task_platform(task: dict[str, Any]) -> str:
4470
+ """Resolve a plataforma da tarefa, incluindo tarefas legadas sem platform."""
4471
+ explicit = str(task.get("platform") or "").strip().casefold()
4472
+ if explicit in {"youtube", "yt"}:
4473
+ return "youtube"
4474
+ if explicit in {"tiktok", "tik-tok", "tt"}:
4475
+ return "tiktok"
4476
+ channel_id = str(task.get("channel_id") or "")
4477
+ if channel_id:
4478
+ channel = next((item for item in read_json("channels.json", []) if isinstance(item, dict) and str(item.get("id")) == channel_id), None)
4479
+ if channel:
4480
+ return classify_channel_platform(channel)
4481
+ format_value = str(task.get("format") or "").strip().casefold()
4482
+ return "tiktok" if format_value in {"portrait", "portrait 9:16", "9:16"} else "youtube"
4483
+
4484
+
4485
+ def load_automation_tasks_for_platform(platform_name: str) -> list[dict[str, Any]]:
4486
+ """Return only automation/catalog tasks belonging to one publishing platform."""
4487
+ target = str(platform_name or "").strip().casefold()
4488
+ return [task for task in load_video_tasks_for_catalog() if task_platform(task) == target]
4489
+
4490
+
4438
4491
  def _task_thumbnail_path(task: dict[str, Any]) -> Path | None:
4439
4492
  """Resolve a generated thumbnail from current and legacy task fields."""
4440
4493
  artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
@@ -4937,6 +4990,36 @@ def render_tiktok_automation():
4937
4990
  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})
4938
4991
  st.success("Automação TikTok guardada.")
4939
4992
  st.rerun()
4993
+ st.divider()
4994
+ st.subheader("Vídeos cadastrados TikTok")
4995
+ st.caption("Esta fila mostra exclusivamente tarefas associadas a canais TikTok.")
4996
+ tiktok_tasks = load_automation_tasks_for_platform("tiktok")
4997
+ if not tiktok_tasks:
4998
+ st.info("Ainda não existem vídeos TikTok cadastrados.")
4999
+ for task in tiktok_tasks:
5000
+ task_id = str(task["id"])
5001
+ with st.container(border=True):
5002
+ cols = st.columns([2.4, 1.4, 1.1, 1.6])
5003
+ with cols[0]:
5004
+ thumbnail_path = _task_thumbnail_path(task)
5005
+ if thumbnail_path:
5006
+ st.image(str(thumbnail_path), width=150, caption="Thumbnail")
5007
+ st.write(f"**{task.get('title') or task.get('topic') or 'Vídeo TikTok'}**")
5008
+ st.caption(f"{task.get('channel_name') or 'Canal TikTok'} · {task_id}")
5009
+ with cols[1]:
5010
+ _render_video_task_state(task)
5011
+ with cols[2]:
5012
+ st.caption("Plataforma")
5013
+ st.write("TikTok")
5014
+ st.caption("Portrait 9:16")
5015
+ with cols[3]:
5016
+ state = str(task.get("state") or "")
5017
+ if st.button("Start", key=f"tiktok_automation_start_{task_id}", disabled=state not in {"to_do", "blocked", "failed"}, use_container_width=True):
5018
+ retry_task_with_current_settings(task_id) if state in {"blocked", "failed"} else transition_task(task_id, "doing")
5019
+ st.rerun()
5020
+ if st.button("Stop", key=f"tiktok_automation_stop_{task_id}", disabled=state != "doing", use_container_width=True):
5021
+ stop_task_by_user(task_id)
5022
+ st.rerun()
4940
5023
 
4941
5024
 
4942
5025
  def render_automation():
@@ -5018,7 +5101,7 @@ def render_automation():
5018
5101
  st.divider()
5019
5102
  st.subheader("Vídeos cadastrados")
5020
5103
  st.caption("Start retoma as etapas já concluídas e só gera novamente o que ainda não estiver pronto. Em tarefas falhadas ou bloqueadas, a nova tentativa lê as chaves, prioridades e configurações actualmente guardadas. Apagar remove o card da fila após confirmação e preserva os artefactos locais.")
5021
- tasks = load_video_tasks_for_catalog()
5104
+ tasks = load_automation_tasks_for_platform("youtube")
5022
5105
  if not tasks:
5023
5106
  st.info("Ainda não existem vídeos cadastrados.")
5024
5107
  for task in tasks:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.84",
3
+ "version": "0.4.86",
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",