@danhachuel/thunderbolt 0.7.52 → 0.7.54

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
@@ -3326,16 +3326,33 @@ def render_channels():
3326
3326
  channels = [channel for channel in read_json("channels.json", []) if is_youtube_channel_record(channel)]
3327
3327
  st.divider()
3328
3328
  st.subheader(f"Canais Youtube cadastrados ({len(channels)})")
3329
- youtube_channel_search = st.text_input(
3330
- "Pesquisar nomes dos canais YouTube",
3331
- key="youtube_registered_channels_search",
3332
- placeholder="Digite o nome do canal",
3333
- )
3329
+ filter_cols = st.columns([1, 1], gap="small")
3330
+ with filter_cols[0]:
3331
+ youtube_channel_search = st.text_input(
3332
+ "Pesquisar nomes dos canais YouTube",
3333
+ key="youtube_registered_channels_search",
3334
+ placeholder="Digite o nome do canal",
3335
+ )
3336
+ with filter_cols[1]:
3337
+ youtube_country_options = [""] + list(COUNTRY_OPTIONS)
3338
+ youtube_country_filter = st.selectbox(
3339
+ "Filtrar por País",
3340
+ youtube_country_options,
3341
+ format_func=lambda value: value or "Todos os países",
3342
+ key="youtube_registered_country_filter",
3343
+ )
3344
+ if st.button("Filtrar", type="primary", width="content", key="youtube_registered_country_filter_apply"):
3345
+ st.session_state["youtube_registered_country_filter_applied"] = youtube_country_filter
3346
+ applied_country = str(st.session_state.get("youtube_registered_country_filter_applied") or "").strip()
3334
3347
  visible_registered_channels = [
3335
3348
  channel for channel in channels
3336
3349
  if not youtube_channel_search.strip()
3337
3350
  or youtube_channel_search.strip().casefold() in str(channel.get("name") or "").casefold()
3338
3351
  ]
3352
+ visible_registered_channels = [
3353
+ channel for channel in visible_registered_channels
3354
+ if not applied_country or str(channel.get("country") or "").strip() == applied_country
3355
+ ]
3339
3356
  if not channels:
3340
3357
  st.info("Nenhum canal YouTube cadastrado.")
3341
3358
  return
@@ -6480,7 +6497,24 @@ def render_automation():
6480
6497
  if not channels:
6481
6498
  st.info("Nenhum canal cadastrado para configurar.")
6482
6499
  with st.expander("Canais cadastrados", expanded=False):
6483
- for channel in channels:
6500
+ automation_country_options = [""] + list(COUNTRY_OPTIONS)
6501
+ automation_filter_cols = st.columns([1, 1], gap="small")
6502
+ with automation_filter_cols[0]:
6503
+ automation_country_filter = st.selectbox(
6504
+ "Filtrar por País",
6505
+ automation_country_options,
6506
+ format_func=lambda value: value or "Todos os países",
6507
+ key="youtube_automation_country_filter",
6508
+ )
6509
+ with automation_filter_cols[1]:
6510
+ if st.button("Filtrar", type="primary", width="content", key="youtube_automation_country_filter_apply"):
6511
+ st.session_state["youtube_automation_country_filter_applied"] = automation_country_filter
6512
+ applied_automation_country = str(st.session_state.get("youtube_automation_country_filter_applied") or "").strip()
6513
+ filtered_automation_channels = [
6514
+ channel for channel in channels
6515
+ if not applied_automation_country or str(channel.get("country") or "").strip() == applied_automation_country
6516
+ ]
6517
+ for channel in filtered_automation_channels:
6484
6518
  channel_id = channel["id"]
6485
6519
  with st.container(border=True):
6486
6520
  blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
@@ -6497,6 +6531,7 @@ def render_automation():
6497
6531
  with header_cols[1]:
6498
6532
  st.write(f"**{channel.get('name', 'Sem nome')}**")
6499
6533
  st.caption(channel.get("handle") or channel.get("url") or "sem URL")
6534
+ st.caption(f"País: {channel.get('country') or 'Não definido'}")
6500
6535
  channel_url = str(channel.get("url") or "").strip()
6501
6536
  if not channel_url:
6502
6537
  channel_id_or_handle = str(channel.get("youtube_channel_id") or channel.get("youtube_id") or channel.get("handle") or "").strip()
@@ -5,6 +5,7 @@ import os
5
5
  import shutil
6
6
  import tempfile
7
7
  import time
8
+ from threading import RLock
8
9
  from contextlib import contextmanager
9
10
  from copy import deepcopy
10
11
  from datetime import datetime, timezone
@@ -24,6 +25,9 @@ SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
24
25
  SEED_THUMBNAIL_BLUEPRINTS = SEED_BLUEPRINTS / "thumbnails"
25
26
  SEED_TIKTOK_PROMPT_MASTERS = ROOT / "seed" / "prompt_masters"
26
27
 
28
+ _READ_CACHE_LOCK = RLock()
29
+ _READ_CACHE: dict[str, tuple[tuple[int, int], Any]] = {}
30
+
27
31
  DEFAULTS: dict[str, Any] = {
28
32
  "channels.json": [],
29
33
  "channel_videos.json": [],
@@ -720,6 +724,19 @@ def _load_json_unlocked(path: Path) -> Any:
720
724
  return json.load(handle)
721
725
 
722
726
 
727
+ def _read_fingerprint(path: Path) -> tuple[int, int] | None:
728
+ try:
729
+ stat = path.stat()
730
+ except OSError:
731
+ return None
732
+ return stat.st_mtime_ns, stat.st_size
733
+
734
+
735
+ def _invalidate_read_cache(path: Path) -> None:
736
+ with _READ_CACHE_LOCK:
737
+ _READ_CACHE.pop(str(path), None)
738
+
739
+
723
740
  def _recover_json_unlocked(name: str, path: Path, default: Any | None) -> Any:
724
741
  backup = _corrupt_backup(path)
725
742
  if name in _PROTECTED_STATE_FILES:
@@ -743,6 +760,13 @@ def _recover_json_unlocked(name: str, path: Path, default: Any | None) -> Any:
743
760
  def read_json(name: str, default: Any | None = None) -> Any:
744
761
  ensure_storage()
745
762
  path = STATE / name
763
+ fingerprint = _read_fingerprint(path)
764
+ cache_key = str(path)
765
+ if fingerprint is not None:
766
+ with _READ_CACHE_LOCK:
767
+ cached = _READ_CACHE.get(cache_key)
768
+ if cached is not None and cached[0] == fingerprint:
769
+ return deepcopy(cached[1])
746
770
  with _state_lock(path, read_only=True):
747
771
  try:
748
772
  data = _load_json_unlocked(path)
@@ -753,7 +777,12 @@ def read_json(name: str, default: Any | None = None) -> Any:
753
777
  migrated, changed = _migrate_settings(data)
754
778
  if changed:
755
779
  _atomic_write_unlocked(path, migrated)
756
- return migrated
780
+ result = migrated
781
+ current_fingerprint = _read_fingerprint(path)
782
+ if current_fingerprint is not None:
783
+ with _READ_CACHE_LOCK:
784
+ _READ_CACHE[cache_key] = (current_fingerprint, deepcopy(result))
785
+ return deepcopy(result)
757
786
 
758
787
 
759
788
  def update_json(name: str, default: Any, mutator: Callable[[Any], Any]) -> Any:
@@ -767,6 +796,7 @@ def update_json(name: str, default: Any, mutator: Callable[[Any], Any]) -> Any:
767
796
  current = _recover_json_unlocked(name, path, default)
768
797
  result = mutator(current)
769
798
  _atomic_write_unlocked(path, current)
799
+ _invalidate_read_cache(path)
770
800
  return result
771
801
 
772
802
 
@@ -774,6 +804,7 @@ def write_json(name: str, data: Any) -> None:
774
804
  """Persist a state JSON file using :func:`atomic_write`."""
775
805
  ensure_storage()
776
806
  atomic_write(STATE / name, data)
807
+ _invalidate_read_cache(STATE / name)
777
808
 
778
809
 
779
810
  def append_json(name: str, item: dict[str, Any]) -> dict[str, Any]:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.52",
3
+ "version": "0.7.54",
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",
package/scripts/cli.mjs CHANGED
@@ -262,16 +262,19 @@ const proxy = http.createServer((request, response) => {
262
262
  headers: { ...request.headers, host: `127.0.0.1:${backendPort}`, "accept-encoding": "identity" },
263
263
  }, (upstreamResponse) => {
264
264
  const responseHeaders = { ...upstreamResponse.headers };
265
- // Streamlit entrega HTML/JS de uma instância local que pode mudar após
266
- // uma atualização via npx. Não permitir que o browser reaproveite uma
267
- // página ou bundle da instância anterior.
268
- delete responseHeaders.etag;
269
- delete responseHeaders["last-modified"];
270
- responseHeaders["cache-control"] = "no-store, no-cache, must-revalidate, max-age=0";
271
- responseHeaders.pragma = "no-cache";
272
- responseHeaders.expires = "0";
273
265
  const contentType = String(responseHeaders["content-type"] || "").toLowerCase();
274
266
  const isHtml = contentType.includes("text/html") && !responseHeaders["content-encoding"];
267
+ // O documento HTML contém o estado da sessão e pode mudar após uma
268
+ // actualização via npx; os bundles estáticos do Streamlit, por outro lado,
269
+ // devem ser reutilizados pelo browser. Desactivar cache em todos os
270
+ // recursos tornava cada aba muito mais pesada e multiplicava downloads.
271
+ if (isHtml) {
272
+ delete responseHeaders.etag;
273
+ delete responseHeaders["last-modified"];
274
+ responseHeaders["cache-control"] = "no-store, no-cache, must-revalidate, max-age=0";
275
+ responseHeaders.pragma = "no-cache";
276
+ responseHeaders.expires = "0";
277
+ }
275
278
  if (!isHtml) {
276
279
  response.writeHead(upstreamResponse.statusCode || 502, responseHeaders);
277
280
  upstreamResponse.pipe(response);