@danhachuel/thunderbolt 0.4.33 → 0.4.34

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
@@ -35,6 +35,7 @@ def display_version(version: str) -> str:
35
35
  APP_VERSION_LABEL = display_version(APP_VERSION)
36
36
 
37
37
  from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, retry_task_with_current_settings, set_channel_defaults, stop_task_by_user, transition_task, update_channel, update_channel_video
38
+ from hermes_ui.channel_import import build_channel_template_xlsx, channel_is_duplicate, find_duplicate_channel, parse_channel_workbook, resolve_blueprint, resolve_google_account, resolve_voice
38
39
  from hermes_ui.drafts import list_drafts, save_draft
39
40
  from hermes_ui.automation_worker import load_worker_status
40
41
  from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
@@ -1849,7 +1850,7 @@ def render_channels():
1849
1850
  youtube_account_labels = {"": "Sem conta Google associada"}
1850
1851
  youtube_account_labels.update({str(account["id"]): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in youtube_accounts})
1851
1852
  youtube_accounts_by_id = {str(account["id"]): account for account in youtube_accounts}
1852
- import_tab, batch_tab, manual_tab = render_localized_tabs(["Importar do YouTube", "Canais em lote gmail", "Cadastro manual"])
1853
+ import_tab, spreadsheet_tab, batch_tab, manual_tab = render_localized_tabs(["Importar do YouTube", "Canais em lote Planilha", "Canais em lote gmail", "Cadastro manual"])
1853
1854
 
1854
1855
  with import_tab:
1855
1856
  st.caption("A pesquisa pública funciona sem API Key. A Data API é opcional e fica disponível numa opção separada para métricas oficiais.")
@@ -1943,6 +1944,142 @@ def render_channels():
1943
1944
  else:
1944
1945
  st.info("Introduza um URL, handle ou ID e clique em Buscar no YouTube. Não é necessária API Key na opção pública.")
1945
1946
 
1947
+ with spreadsheet_tab:
1948
+ st.caption("Carregue uma planilha Excel (.xlsx ou .xls). Os cabeçalhos e valores são interpretados, normalizados e associados aos cadastros existentes antes da gravação.")
1949
+ download_col, help_col = st.columns([1.35, 2.65])
1950
+ with download_col:
1951
+ st.download_button(
1952
+ "Baixar planilha modelo",
1953
+ data=build_channel_template_xlsx(),
1954
+ file_name="modelo_canais_youtube.xlsx",
1955
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1956
+ use_container_width=True,
1957
+ key="download_channel_spreadsheet_template",
1958
+ )
1959
+ with help_col:
1960
+ st.caption("O modelo inclui as 15 colunas aceitas. Deixe campos não utilizados vazios; a Descrição será pesquisada publicamente pelo handle, URL ou nome quando estiver vazia.")
1961
+
1962
+ uploaded_sheet = st.file_uploader(
1963
+ "Upload da planilha de canais",
1964
+ type=["xlsx", "xls"],
1965
+ key="channel_spreadsheet_upload",
1966
+ help="Apenas a primeira aba do arquivo será importada. Linhas sem URL, nome e handle são ignoradas.",
1967
+ )
1968
+ if st.button("Ler e preparar planilha", type="primary", use_container_width=True, key="read_channel_spreadsheet"):
1969
+ if uploaded_sheet is None:
1970
+ st.error("Selecione um arquivo Excel antes de continuar.")
1971
+ else:
1972
+ try:
1973
+ spreadsheet_rows, spreadsheet_warnings = parse_channel_workbook(uploaded_sheet.getvalue(), uploaded_sheet.name)
1974
+ st.session_state["channel_spreadsheet_rows"] = spreadsheet_rows
1975
+ st.session_state["channel_spreadsheet_warnings"] = spreadsheet_warnings
1976
+ st.session_state.pop("channel_spreadsheet_result", None)
1977
+ if spreadsheet_rows:
1978
+ st.success(f"{len(spreadsheet_rows)} linha(s) de canal preparada(s) para revisão.")
1979
+ else:
1980
+ st.warning("A planilha não contém linhas com URL, nome ou handle de canal.")
1981
+ except ValueError as exc:
1982
+ st.session_state.pop("channel_spreadsheet_rows", None)
1983
+ st.error(str(exc))
1984
+
1985
+ spreadsheet_warnings = st.session_state.get("channel_spreadsheet_warnings", [])
1986
+ for spreadsheet_warning in spreadsheet_warnings:
1987
+ st.warning(spreadsheet_warning)
1988
+ spreadsheet_result = st.session_state.get("channel_spreadsheet_result")
1989
+ if isinstance(spreadsheet_result, dict):
1990
+ if spreadsheet_result.get("created"):
1991
+ st.success(f"Canais cadastrados: {', '.join(spreadsheet_result['created'])}")
1992
+ if spreadsheet_result.get("skipped"):
1993
+ st.info(f"Já cadastrados e não duplicados: {', '.join(spreadsheet_result['skipped'])}")
1994
+ for spreadsheet_error in spreadsheet_result.get("errors", []):
1995
+ st.warning(spreadsheet_error)
1996
+
1997
+ spreadsheet_rows = st.session_state.get("channel_spreadsheet_rows", [])
1998
+ if spreadsheet_rows:
1999
+ blueprint_items = blueprint_catalog()
2000
+ voice_options = voice_catalog()
2001
+ spreadsheet_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
2002
+ existing_spreadsheet_channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict)]
2003
+ preview_rows = []
2004
+ for row in spreadsheet_rows:
2005
+ resolved_blueprint = resolve_blueprint(row.get("blueprint"), blueprint_items)
2006
+ resolved_voice = resolve_voice(row.get("voice"), voice_options)
2007
+ resolved_account, _ = resolve_google_account(row.get("google_account"), spreadsheet_accounts)
2008
+ preview_rows.append({
2009
+ "Linha": row.get("_source_row", "—"),
2010
+ "Nome": row.get("name") or "—",
2011
+ "Handle": row.get("handle") or "—",
2012
+ "Blueprint interpretado": resolved_blueprint or "—",
2013
+ "Voz interpretada": resolved_voice or "—",
2014
+ "Idioma": language_code(row.get("language")) if row.get("language") else "—",
2015
+ "Descrição": "Preencher via YouTube" if not row.get("description") else "Da planilha",
2016
+ "Estado": "Já cadastrado" if find_duplicate_channel({"name": row.get("name"), "handle": row.get("handle"), "url": row.get("url")}, existing_spreadsheet_channels) else "Novo",
2017
+ })
2018
+ st.dataframe(preview_rows, use_container_width=True, hide_index=True, height=min(420, 86 + 38 * len(preview_rows)))
2019
+ st.caption("Blueprints e vozes são resolvidos pelos catálogos atuais. Por exemplo, `finanças`, `blueprint_finanças` e `Blueprint Canal Finanças` apontam para o mesmo Blueprint quando ele existe.")
2020
+ if st.button("Cadastrar canais da planilha", type="primary", use_container_width=True, key="import_channel_spreadsheet"):
2021
+ created_names: list[str] = []
2022
+ skipped_names: list[str] = []
2023
+ spreadsheet_errors: list[str] = []
2024
+ current_channels = list(existing_spreadsheet_channels)
2025
+ for row in spreadsheet_rows:
2026
+ row_label = str(row.get("name") or row.get("handle") or row.get("url") or f"Linha {row.get('_source_row', '?')}")
2027
+ candidate = {"name": row.get("name", ""), "handle": row.get("handle", ""), "url": row.get("url", "")}
2028
+ duplicate = find_duplicate_channel(candidate, current_channels)
2029
+ if duplicate:
2030
+ skipped_names.append(f"{row_label} (linha {row.get('_source_row', '?')})")
2031
+ continue
2032
+ automation_time = str(row.get("automation_time") or "").strip()
2033
+ if automation_time and not valid_hhmm(automation_time):
2034
+ spreadsheet_errors.append(f"{row_label} (linha {row.get('_source_row', '?')}): horário inválido; a linha não foi cadastrada.")
2035
+ continue
2036
+ try:
2037
+ resolved_blueprint = resolve_blueprint(row.get("blueprint"), blueprint_items)
2038
+ resolved_voice = resolve_voice(row.get("voice"), voice_options)
2039
+ google_account_id, google_account_email = resolve_google_account(row.get("google_account"), spreadsheet_accounts)
2040
+ description = str(row.get("description") or "").strip()
2041
+ description_status = "da planilha"
2042
+ if not description:
2043
+ lookup_source = str(row.get("handle") or row.get("url") or row.get("name") or "").strip()
2044
+ if lookup_source:
2045
+ description_result = youtube.fetch_channel_public(lookup_source)
2046
+ description = str(description_result.data.get("description") or "").strip()
2047
+ if not description and youtube.api_key:
2048
+ api_description_result = youtube.fetch_channel(lookup_source)
2049
+ description = str(api_description_result.data.get("description") or "").strip()
2050
+ description_status = "buscada no YouTube" if description else "não encontrada"
2051
+ style_value = str(row.get("style_wide") or "").strip()
2052
+ metadata = {
2053
+ "handle": str(row.get("handle") or "").strip(),
2054
+ "description": description,
2055
+ "niche": str(row.get("niche") or "").strip(),
2056
+ "reference_channels": [item.strip() for item in re.split(r"[,|]", str(row.get("niche") or "")) if item.strip()],
2057
+ "language": language_code(row.get("language")) if row.get("language") else "",
2058
+ "style_wide": style_value,
2059
+ "blueprint_id": resolved_blueprint,
2060
+ "default_blueprint_id": resolved_blueprint,
2061
+ "default_voice": resolved_voice,
2062
+ "voice": resolved_voice,
2063
+ "google_account_id": google_account_id,
2064
+ "google_account_email": google_account_email,
2065
+ "automation_on": bool(row.get("automation_on")) if row.get("automation_on") is not None else False,
2066
+ "automation_time": automation_time,
2067
+ "delegated_session_id": str(row.get("delegated_session_id") or "").strip(),
2068
+ "active": bool(row.get("active")) if row.get("active") is not None else True,
2069
+ "default_video_duration_minutes": row.get("duration_minutes"),
2070
+ "metrics_source": "spreadsheet",
2071
+ "import_source": "spreadsheet",
2072
+ "description_source": description_status,
2073
+ }
2074
+ created = create_channel(str(row.get("name") or "").strip(), str(row.get("url") or "").strip(), metadata)
2075
+ current_channels.append(created)
2076
+ created_names.append(row_label)
2077
+ except Exception as exc:
2078
+ spreadsheet_errors.append(f"{row_label} (linha {row.get('_source_row', '?')}): {exc}")
2079
+ st.session_state["channel_spreadsheet_result"] = {"created": created_names, "skipped": skipped_names, "errors": spreadsheet_errors}
2080
+ st.session_state.pop("channel_spreadsheet_rows", None)
2081
+ st.rerun()
2082
+
1946
2083
  with batch_tab:
1947
2084
  st.caption("Esta subaba usa a conta Google/YouTube seleccionada para listar os canais que ela gere. Não lê a caixa Gmail e não usa e-mails como pesquisa pública.")
1948
2085
  accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
@@ -2094,6 +2231,34 @@ def render_channels():
2094
2231
  if not channels:
2095
2232
  st.info("Nenhum canal cadastrado.")
2096
2233
  return
2234
+ channel_rows = [
2235
+ {
2236
+ "Nome": str(channel.get("name") or ""),
2237
+ "Handle": str(channel.get("handle") or ""),
2238
+ "URL": str(channel.get("url") or ""),
2239
+ "Nicho": channel_niche_label(channel),
2240
+ "Descrição": str(channel.get("description") or ""),
2241
+ "Origem": str(channel.get("import_source") or channel.get("metrics_source") or "manual"),
2242
+ "Activo": "Sim" if channel.get("active", True) else "Não",
2243
+ }
2244
+ for channel in channels
2245
+ ]
2246
+ st.dataframe(
2247
+ channel_rows,
2248
+ use_container_width=True,
2249
+ hide_index=True,
2250
+ height=min(360, 76 + 38 * len(channel_rows)),
2251
+ column_config={
2252
+ "Nome": st.column_config.TextColumn("Nome", width=180),
2253
+ "Handle": st.column_config.TextColumn("Handle", width=150),
2254
+ "URL": st.column_config.LinkColumn("URL", width=220),
2255
+ "Nicho": st.column_config.TextColumn("Nicho", width=160),
2256
+ "Descrição": st.column_config.TextColumn("Descrição", width=360),
2257
+ "Origem": st.column_config.TextColumn("Origem", width=130),
2258
+ "Activo": st.column_config.TextColumn("Activo", width=80),
2259
+ },
2260
+ )
2261
+ st.caption("Tabela de canais cadastrados. Os cartões abaixo mantêm as ações de edição, credenciais e vídeos publicados.")
2097
2262
  for channel in channels:
2098
2263
  channel_id = str(channel["id"])
2099
2264
  edit_key = f"edit_channel_{channel_id}"
@@ -7335,10 +7500,16 @@ def main():
7335
7500
  }
7336
7501
  all_children = [item for items in groups.values() for item in items]
7337
7502
  valid_targets = {item[0] for item in top_pages + all_children}
7338
- current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
7503
+ # Session state is reset by a browser refresh or an application update. Keep
7504
+ # the canonical page in the URL so the open section can be restored.
7505
+ query_page = str(st.query_params.get("page") or "").strip()
7506
+ stored_page = query_page or str(st.session_state.get("page", "Início"))
7507
+ current_page = aliases.get(stored_page, stored_page)
7339
7508
  if current_page not in valid_targets:
7340
7509
  current_page = "Início"
7341
7510
  st.session_state["page"] = current_page
7511
+ if str(st.query_params.get("page") or "") != current_page:
7512
+ st.query_params["page"] = current_page
7342
7513
  ui_language = current_ui_language()
7343
7514
  current_path = nav_paths.get(current_page, "/inicio")
7344
7515
 
@@ -7352,6 +7523,7 @@ def main():
7352
7523
 
7353
7524
  def navigate(target: str):
7354
7525
  st.session_state["page"] = target
7526
+ st.query_params["page"] = target
7355
7527
  st.rerun()
7356
7528
 
7357
7529
  def render_nav_button(target: str, icon: str, label: str, scope: str):
@@ -0,0 +1,429 @@
1
+ """Helpers for importing YouTube channel registrations from spreadsheets.
2
+
3
+ The spreadsheet is intentionally treated as a human-authored document rather
4
+ than a database export: headers and values are normalised semantically, common
5
+ Portuguese/English variants are accepted, and catalog-backed values are
6
+ resolved to the application's canonical identifiers before persistence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import date, datetime, time
12
+ from difflib import SequenceMatcher
13
+ from io import BytesIO
14
+ import math
15
+ import re
16
+ import unicodedata
17
+ from copy import copy
18
+ from typing import Any, Iterable, Mapping, Sequence
19
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
20
+
21
+ import pandas as pd
22
+
23
+ from .languages import language_code
24
+
25
+
26
+ CHANNEL_TEMPLATE_COLUMNS: tuple[str, ...] = (
27
+ "URL canal",
28
+ "Nome canal",
29
+ "Handle canal",
30
+ "Narrador/ voz padrão",
31
+ "Idioma",
32
+ "Nicho",
33
+ "Blueprint Padrão",
34
+ "Estilo Wide",
35
+ "Activo ",
36
+ "Descrição",
37
+ "Conta Google do Documento deste Canal",
38
+ "Automação Ligada ",
39
+ "Horário diário (HH:MM)",
40
+ "DELEGATED_SESSION_ID",
41
+ "Duração Padrão Vídeos (Min)",
42
+ )
43
+
44
+
45
+ _FIELD_ALIASES: dict[str, tuple[str, ...]] = {
46
+ "url": ("url canal", "url", "link canal", "link", "youtube url", "youtube link", "channel url"),
47
+ "name": ("nome canal", "nome", "nome do canal", "channel name", "name"),
48
+ "handle": ("handle canal", "handle", "identificador canal", "youtube handle", "channel handle"),
49
+ "voice": ("narrador voz padrão", "narrador voz padrao", "narrador", "voz padrão", "voz padrao", "voz", "voice"),
50
+ "language": ("idioma", "lingua", "language", "lang"),
51
+ "niche": ("nicho", "niche", "tema", "categoria", "category"),
52
+ "blueprint": ("blueprint padrão", "blueprint padrao", "blueprint", "modelo blueprint", "blueprint name"),
53
+ "style_wide": ("estilo wide", "estilo", "wide style", "video style", "style"),
54
+ "active": ("activo", "ativo", "activa", "ativa", "active", "enabled"),
55
+ "description": ("descrição", "descricao", "description", "sobre o canal", "channel description"),
56
+ "google_account": (
57
+ "conta google do documento deste canal",
58
+ "conta google",
59
+ "google account",
60
+ "gmail",
61
+ "email google",
62
+ "e mail google",
63
+ ),
64
+ "automation_on": ("automação ligada", "automacao ligada", "automação", "automacao", "automation", "automation on"),
65
+ "automation_time": ("horário diário hh mm", "horario diario hh mm", "horário diário", "horario diario", "automation time", "schedule"),
66
+ "delegated_session_id": ("delegated session id", "delegated_session_id", "delegated session", "session id"),
67
+ "duration_minutes": (
68
+ "duração padrão vídeos min",
69
+ "duracao padrao videos min",
70
+ "duração padrão vídeos",
71
+ "duracao padrao videos",
72
+ "duração vídeos min",
73
+ "video duration minutes",
74
+ "duration minutes",
75
+ ),
76
+ }
77
+
78
+
79
+ def _strip_accents(value: Any) -> str:
80
+ text = str(value or "")
81
+ return "".join(char for char in unicodedata.normalize("NFKD", text) if not unicodedata.combining(char))
82
+
83
+
84
+ def _text(value: Any) -> str:
85
+ if value is None:
86
+ return ""
87
+ try:
88
+ if bool(pd.isna(value)):
89
+ return ""
90
+ except (TypeError, ValueError):
91
+ pass
92
+ if isinstance(value, float) and math.isnan(value):
93
+ return ""
94
+ return str(value).strip()
95
+
96
+
97
+ def _key(value: Any) -> str:
98
+ return re.sub(r"[^a-z0-9]+", "", _strip_accents(value).casefold())
99
+
100
+
101
+ def _semantic_key(value: Any) -> str:
102
+ """Return a comparison key that ignores catalog naming conventions.
103
+
104
+ For example, ``finanças``, ``blueprint_finanças`` and
105
+ ``Blueprint Canal Finanças`` all become ``financas``.
106
+ """
107
+ compact = _key(value)
108
+ for generic in ("blueprints", "blueprint", "canal", "channel", "padrao", "default", "modelo"):
109
+ compact = compact.replace(generic, "")
110
+ return compact
111
+
112
+
113
+ def _header_match(header: Any) -> str | None:
114
+ normalized = _key(header)
115
+ if not normalized:
116
+ return None
117
+ aliases: dict[str, str] = {}
118
+ for field, values in _FIELD_ALIASES.items():
119
+ for alias in values:
120
+ aliases[_key(alias)] = field
121
+ if normalized in aliases:
122
+ return aliases[normalized]
123
+ # Spreadsheet authors often add a unit or a harmless prefix/suffix.
124
+ ranked: list[tuple[float, str]] = []
125
+ for alias, field in aliases.items():
126
+ if alias and (alias in normalized or normalized in alias):
127
+ ranked.append((min(len(alias), len(normalized)) / max(len(alias), len(normalized)), field))
128
+ if ranked:
129
+ return max(ranked)[1]
130
+ return None
131
+
132
+
133
+ def parse_channel_workbook(file_content: bytes, filename: str = "channels.xlsx") -> tuple[list[dict[str, Any]], list[str]]:
134
+ """Read the first worksheet and return normalised source rows.
135
+
136
+ Blank rows are discarded. Unknown columns are ignored and reported so a
137
+ user can still import a workbook containing extra operational columns.
138
+ """
139
+ suffix = str(filename or "").lower().rsplit(".", 1)[-1] if "." in str(filename) else "xlsx"
140
+ engine = "xlrd" if suffix == "xls" else "openpyxl"
141
+ try:
142
+ frame = pd.read_excel(BytesIO(file_content), sheet_name=0, dtype=object, engine=engine)
143
+ except ImportError as exc:
144
+ raise ValueError("Para ficheiros .xls, instale o suporte xlrd; para .xlsx, use o formato Excel moderno (.xlsx).") from exc
145
+ except Exception as exc:
146
+ raise ValueError(f"Não foi possível ler a planilha Excel: {exc}") from exc
147
+
148
+ warnings: list[str] = []
149
+ field_by_column: dict[Any, str] = {}
150
+ for column in frame.columns:
151
+ field = _header_match(column)
152
+ if field is None:
153
+ warnings.append(f"Coluna ignorada: {str(column).strip() or '(sem nome)'}")
154
+ continue
155
+ if field in field_by_column.values():
156
+ warnings.append(f"Coluna duplicada ignorada para {field}: {str(column).strip()}")
157
+ continue
158
+ field_by_column[column] = field
159
+
160
+ if not any(field in field_by_column.values() for field in ("url", "name", "handle")):
161
+ expected = ", ".join(("URL canal", "Nome canal", "Handle canal"))
162
+ raise ValueError(f"A planilha precisa de pelo menos uma coluna de identificação: {expected}.")
163
+
164
+ rows: list[dict[str, Any]] = []
165
+ for source_index, (_, record) in enumerate(frame.iterrows(), start=2):
166
+ if all(not _text(value) for value in record.tolist()):
167
+ continue
168
+ row: dict[str, Any] = {field: record[column] for column, field in field_by_column.items()}
169
+ row["_source_row"] = source_index
170
+ normalized = normalize_channel_row(row)
171
+ # The public template contains pre-filled defaults such as False and
172
+ # 10 minutes. They do not make a row a channel registration.
173
+ if not any(normalized.get(field) for field in ("url", "name", "handle")):
174
+ continue
175
+ rows.append(normalized)
176
+ return rows, warnings
177
+
178
+
179
+ def normalize_handle(value: Any) -> str:
180
+ raw = _text(value)
181
+ if not raw:
182
+ return ""
183
+ match = re.search(r"(?:youtube\.com/)?@([A-Za-z0-9_.-]+)", raw, flags=re.IGNORECASE)
184
+ if match:
185
+ return f"@{match.group(1)}"
186
+ return raw if raw.startswith("@") else f"@{raw}"
187
+
188
+
189
+ def _normalise_boolean(value: Any) -> bool | None:
190
+ if isinstance(value, bool):
191
+ return value
192
+ raw = _strip_accents(_text(value)).casefold()
193
+ if not raw:
194
+ return None
195
+ if raw in {"1", "true", "t", "yes", "y", "sim", "s", "ativo", "activa", "ativa", "ligado", "ligada", "on", "x"}:
196
+ return True
197
+ if raw in {"0", "false", "f", "no", "n", "nao", "inativo", "inactiva", "inativa", "desligado", "desligada", "off"}:
198
+ return False
199
+ if raw.endswith(".0") and raw[:-2] in {"0", "1"}:
200
+ return raw[:-2] == "1"
201
+ return None
202
+
203
+
204
+ def _normalise_time(value: Any) -> str:
205
+ if value is None:
206
+ return ""
207
+ if isinstance(value, datetime):
208
+ return value.strftime("%H:%M")
209
+ if isinstance(value, time):
210
+ return value.strftime("%H:%M")
211
+ raw = _text(value)
212
+ if not raw:
213
+ return ""
214
+ match = re.search(r"\b([01]?\d|2[0-3])\s*[:hH]\s*([0-5]\d)\b", raw)
215
+ if match:
216
+ return f"{int(match.group(1)):02d}:{int(match.group(2)):02d}"
217
+ return raw
218
+
219
+
220
+ def _normalise_duration(value: Any) -> float | None:
221
+ if value is None:
222
+ return None
223
+ if isinstance(value, datetime):
224
+ return float(value.hour * 60 + value.minute) or None
225
+ if isinstance(value, time):
226
+ return float(value.hour * 60 + value.minute) or None
227
+ raw = _text(value)
228
+ if not raw:
229
+ return None
230
+ match = re.search(r"(\d+(?:[.,]\d+)?)\s*(?:min|m|minutes?)?", raw, flags=re.IGNORECASE)
231
+ if match:
232
+ try:
233
+ parsed = float(match.group(1).replace(",", "."))
234
+ return parsed if parsed > 0 else None
235
+ except ValueError:
236
+ return None
237
+ return None
238
+
239
+
240
+ def _normalise_style(value: Any) -> str:
241
+ raw = _strip_accents(_text(value)).casefold()
242
+ if not raw:
243
+ return ""
244
+ if any(token in raw for token in ("musica", "music", "audio only", "so audio")):
245
+ return "music"
246
+ if any(token in raw for token in ("full", "ia", "ai", "gerado", "artificial")):
247
+ return "full_ia"
248
+ if any(token in raw for token in ("pexels", "pixabay", "stock", "banco", "material")):
249
+ return "pexels"
250
+ return _text(value)
251
+
252
+
253
+ def normalize_channel_row(row: Mapping[str, Any]) -> dict[str, Any]:
254
+ """Convert raw spreadsheet values into the application's field vocabulary."""
255
+ normalized: dict[str, Any] = {
256
+ "url": _text(row.get("url")),
257
+ "name": _text(row.get("name")),
258
+ "handle": normalize_handle(row.get("handle")),
259
+ "voice": _text(row.get("voice")),
260
+ "language": _text(row.get("language")),
261
+ "niche": _text(row.get("niche")),
262
+ "blueprint": _text(row.get("blueprint")),
263
+ "style_wide": _normalise_style(row.get("style_wide")),
264
+ "active": _normalise_boolean(row.get("active")),
265
+ "description": _text(row.get("description")),
266
+ "google_account": _text(row.get("google_account")),
267
+ "automation_on": _normalise_boolean(row.get("automation_on")),
268
+ "automation_time": _normalise_time(row.get("automation_time")),
269
+ "delegated_session_id": _text(row.get("delegated_session_id")),
270
+ "duration_minutes": _normalise_duration(row.get("duration_minutes")),
271
+ }
272
+ if "_source_row" in row:
273
+ normalized["_source_row"] = row["_source_row"]
274
+ return normalized
275
+
276
+
277
+ def resolve_blueprint(value: Any, catalog: Sequence[tuple[str, str]]) -> str:
278
+ """Resolve a human label or filename-like value to a Blueprint id."""
279
+ raw = _text(value)
280
+ if not raw:
281
+ return ""
282
+ raw_key = _key(raw)
283
+ raw_semantic = _semantic_key(raw)
284
+ candidates = [(str(identifier), str(label)) for identifier, label in catalog if str(identifier).strip()]
285
+ for identifier, label in candidates:
286
+ if raw_key in {_key(identifier), _key(label)}:
287
+ return identifier
288
+ if raw_semantic:
289
+ semantic_matches = [(identifier, label) for identifier, label in candidates if raw_semantic in {_semantic_key(identifier), _semantic_key(label)}]
290
+ if len(semantic_matches) == 1:
291
+ return semantic_matches[0][0]
292
+ if semantic_matches:
293
+ return max(semantic_matches, key=lambda item: SequenceMatcher(None, raw_semantic, _semantic_key(item[1])).ratio())[0]
294
+ scored: list[tuple[float, str]] = []
295
+ for identifier, label in candidates:
296
+ score = max(SequenceMatcher(None, raw_key, _key(identifier)).ratio(), SequenceMatcher(None, raw_key, _key(label)).ratio())
297
+ if score >= 0.72:
298
+ scored.append((score, identifier))
299
+ if scored:
300
+ return max(scored)[1]
301
+ return raw
302
+
303
+
304
+ def resolve_voice(value: Any, catalog: Iterable[str]) -> str:
305
+ raw = _text(value)
306
+ if not raw:
307
+ return ""
308
+ options = [str(item) for item in catalog if str(item).strip()]
309
+ raw_key = _key(raw)
310
+ for option in options:
311
+ option_key = _key(option)
312
+ if raw_key == option_key or raw_key in option_key or option_key in raw_key:
313
+ return option
314
+ scored = [(SequenceMatcher(None, raw_key, _key(option)).ratio(), option) for option in options]
315
+ best = max(scored, default=(0.0, ""))
316
+ return best[1] if best[0] >= 0.72 else raw
317
+
318
+
319
+ def resolve_google_account(value: Any, accounts: Iterable[Mapping[str, Any]]) -> tuple[str, str]:
320
+ """Resolve an email, id or display label to ``(id, email)``."""
321
+ raw = _text(value)
322
+ if not raw:
323
+ return "", ""
324
+ raw_key = _key(raw)
325
+ candidates: list[tuple[str, str, str]] = []
326
+ for account in accounts:
327
+ identifier = _text(account.get("id"))
328
+ if not identifier:
329
+ continue
330
+ email = _text(account.get("email"))
331
+ label = _text(account.get("label"))
332
+ candidates.append((identifier, email, label))
333
+ if raw_key in {_key(identifier), _key(email), _key(label)}:
334
+ return identifier, email
335
+ scored = []
336
+ for identifier, email, label in candidates:
337
+ score = max(SequenceMatcher(None, raw_key, _key(email)).ratio(), SequenceMatcher(None, raw_key, _key(label)).ratio())
338
+ scored.append((score, identifier, email))
339
+ best = max(scored, default=(0.0, "", ""))
340
+ return (best[1], best[2]) if best[0] >= 0.78 else ("", raw)
341
+
342
+
343
+ def _canonical_url(value: Any) -> str:
344
+ raw = _text(value)
345
+ if not raw:
346
+ return ""
347
+ if not re.match(r"^https?://", raw, flags=re.IGNORECASE):
348
+ raw = f"https://{raw}"
349
+ try:
350
+ parsed = urlsplit(raw)
351
+ query = [(key, val) for key, val in parse_qsl(parsed.query, keep_blank_values=True) if key.casefold() not in {"utm_source", "utm_medium", "utm_campaign", "si"}]
352
+ return urlunsplit((parsed.scheme.casefold(), parsed.netloc.casefold(), parsed.path.rstrip("/").casefold(), urlencode(query), ""))
353
+ except ValueError:
354
+ return _key(raw)
355
+
356
+
357
+ def _identity_text(value: Any) -> str:
358
+ return _key(value)
359
+
360
+
361
+ def channel_is_duplicate(candidate: Mapping[str, Any], existing: Mapping[str, Any]) -> bool:
362
+ """Return true when two records identify the same channel.
363
+
364
+ Handle, canonical URL and YouTube id are strong identifiers. A name is a
365
+ fallback only when one side has no handle, avoiding false positives for
366
+ two channels that happen to share a common name.
367
+ """
368
+ candidate_id = _key(candidate.get("youtube_channel_id") or candidate.get("youtube_id"))
369
+ existing_id = _key(existing.get("youtube_channel_id") or existing.get("youtube_id"))
370
+ if candidate_id and existing_id and candidate_id == existing_id:
371
+ return True
372
+ candidate_handle = _identity_text(candidate.get("handle"))
373
+ existing_handle = _identity_text(existing.get("handle"))
374
+ if candidate_handle and existing_handle and candidate_handle == existing_handle:
375
+ return True
376
+ candidate_url = _canonical_url(candidate.get("url"))
377
+ existing_url = _canonical_url(existing.get("url"))
378
+ if candidate_url and existing_url and candidate_url == existing_url:
379
+ return True
380
+ candidate_name = _identity_text(candidate.get("name"))
381
+ existing_name = _identity_text(existing.get("name"))
382
+ return bool(candidate_name and existing_name and candidate_name == existing_name and not (candidate_handle and existing_handle and candidate_handle != existing_handle))
383
+
384
+
385
+ def find_duplicate_channel(candidate: Mapping[str, Any], channels: Iterable[Mapping[str, Any]]) -> Mapping[str, Any] | None:
386
+ return next((channel for channel in channels if isinstance(channel, Mapping) and channel_is_duplicate(candidate, channel)), None)
387
+
388
+
389
+ def build_channel_template_xlsx() -> bytes:
390
+ """Build a fresh, formatted workbook using the public template columns."""
391
+ frame = pd.DataFrame([{column: "" for column in CHANNEL_TEMPLATE_COLUMNS} for _ in range(20)])
392
+ frame["Automação Ligada "] = False
393
+ frame["Activo "] = True
394
+ frame["Horário diário (HH:MM)"] = "00:00"
395
+ frame["Duração Padrão Vídeos (Min)"] = 10
396
+ output = BytesIO()
397
+ with pd.ExcelWriter(output, engine="openpyxl") as writer:
398
+ frame.to_excel(writer, index=False, sheet_name="Canais YouTube")
399
+ worksheet = writer.book["Canais YouTube"]
400
+ worksheet.freeze_panes = "A2"
401
+ worksheet.auto_filter.ref = worksheet.dimensions
402
+ for cell in worksheet[1]:
403
+ font = copy(cell.font)
404
+ font.bold = True
405
+ font.color = "FFFFFF"
406
+ cell.font = font
407
+ fill = copy(cell.fill)
408
+ fill.fill_type = "solid"
409
+ fill.fgColor = "1F4E78"
410
+ cell.fill = fill
411
+ widths = {column: max(18, min(42, len(column) + 4)) for column in CHANNEL_TEMPLATE_COLUMNS}
412
+ widths["Descrição"] = 48
413
+ widths["URL canal"] = 38
414
+ for index, column in enumerate(CHANNEL_TEMPLATE_COLUMNS, start=1):
415
+ worksheet.column_dimensions[chr(64 + index) if index <= 26 else worksheet.cell(1, index).column_letter].width = widths[column]
416
+ return output.getvalue()
417
+
418
+
419
+ __all__ = [
420
+ "CHANNEL_TEMPLATE_COLUMNS",
421
+ "build_channel_template_xlsx",
422
+ "channel_is_duplicate",
423
+ "find_duplicate_channel",
424
+ "normalize_channel_row",
425
+ "parse_channel_workbook",
426
+ "resolve_blueprint",
427
+ "resolve_google_account",
428
+ "resolve_voice",
429
+ ]
@@ -35,6 +35,7 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
35
35
  "url": url.strip(),
36
36
  "handle": "",
37
37
  "description": "",
38
+ "description_source": "",
38
39
  "niche": "",
39
40
  "reference_channels": [],
40
41
  "thumbnail_url": "",
@@ -56,6 +57,8 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
56
57
  "automation_time": "00:00",
57
58
  "active": True,
58
59
  "daily_limit": 1,
60
+ "default_video_duration_minutes": None,
61
+ "import_source": "manual",
59
62
  "backlog_total": 0,
60
63
  "created_at": now(),
61
64
  "updated_at": now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.33",
3
+ "version": "0.4.34",
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/requirements.txt CHANGED
@@ -2,6 +2,8 @@ streamlit>=1.37,<2
2
2
  requests[socks]>=2.31,<3
3
3
  Pillow>=10,<12
4
4
  pandas>=2.0,<3
5
+ openpyxl>=3.1,<4
6
+ xlrd>=2.0,<3
5
7
  imageio-ffmpeg>=0.6,<1
6
8
  toml>=0.10,<1
7
9
  google-auth>=2.29,<3