@danhachuel/leadmaster 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,460 @@
1
+ """Persistência 100% local em arquivos JSON.
2
+
3
+ Toda a escrita é atômica (arquivo temporário + replace) e protegida por um lock
4
+ global, para permitir o uso concorrente pelas rotas da API e pelos workers em
5
+ background (pipeline, campanhas, agendador de publicação).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import threading
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ LOCK = threading.RLock()
15
+
16
+ # Campos que nunca podem vazar para o frontend.
17
+ SECRET_KEYS = {"api_key", "token", "senha", "password"}
18
+
19
+ LEADS_FILE = "leads.json"
20
+ CAMPAIGNS_FILE = "campaigns.json"
21
+ PIPELINE_FILE = "pipeline.json"
22
+ CONFIG_FILE = "config.json"
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Caminhos
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _repo_root() -> Path:
30
+ """Raiz do repositório (pasta que contém backend/). storage.py está em
31
+ backend/services/, então sobe três níveis."""
32
+ return Path(__file__).resolve().parent.parent.parent
33
+
34
+
35
+ def get_home() -> Path:
36
+ """Pasta de dados (home). Ordem: env LEADMASTER_HOME → raiz do repo."""
37
+ env = os.environ.get("LEADMASTER_HOME")
38
+ if env:
39
+ return Path(env).resolve()
40
+ return _repo_root()
41
+
42
+
43
+ def storage_dir() -> Path:
44
+ d = get_home() / "storage"
45
+ d.mkdir(parents=True, exist_ok=True)
46
+ return d
47
+
48
+
49
+ def roteiros_dir() -> Path:
50
+ d = get_home() / "roteiros"
51
+ d.mkdir(parents=True, exist_ok=True)
52
+ return d
53
+
54
+
55
+ def videos_dir() -> Path:
56
+ d = get_home() / "videos"
57
+ d.mkdir(parents=True, exist_ok=True)
58
+ return d
59
+
60
+
61
+ def now_iso() -> str:
62
+ return datetime.now().astimezone().isoformat(timespec="seconds")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Leitura/escrita atômica
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def _read(name: str, default):
70
+ with LOCK:
71
+ path = storage_dir() / name
72
+ if not path.exists():
73
+ return default
74
+ try:
75
+ # utf-8-sig: tolera BOM (comum em arquivos editados no Windows
76
+ # por PowerShell/Notepad) e é idêntico a utf-8 quando não há BOM.
77
+ return json.loads(path.read_text(encoding="utf-8-sig"))
78
+ except json.JSONDecodeError:
79
+ # Arquivo corrompido: preserva uma cópia para inspeção manual
80
+ # e devolve o padrão para não travar a aplicação.
81
+ try:
82
+ path.rename(path.with_suffix(path.suffix + ".corrompido"))
83
+ except OSError:
84
+ pass
85
+ return default
86
+
87
+
88
+ def _write(name: str, data) -> None:
89
+ with LOCK:
90
+ path = storage_dir() / name
91
+ tmp = path.with_name(path.name + ".tmp")
92
+ tmp.write_text(
93
+ json.dumps(data, ensure_ascii=False, indent=2),
94
+ encoding="utf-8",
95
+ )
96
+ tmp.replace(path)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Configuração
101
+ # ---------------------------------------------------------------------------
102
+
103
+ DEFAULT_CONFIG = {
104
+ "perfil": {
105
+ "nome": "Dan",
106
+ "licenca": {
107
+ "ativa": True,
108
+ "expira_em": "31/12/2099 23:59",
109
+ "restam_dias": None,
110
+ },
111
+ },
112
+ "llm": {
113
+ "provedor": "openai-compat",
114
+ "base_url": "https://api.openai.com/v1",
115
+ "api_key": "",
116
+ "modelo": "gpt-4o-mini",
117
+ },
118
+ "integracoes": {
119
+ "moneyprinter": {"url": "http://localhost:8080"},
120
+ "short_video_maker": {"url": "http://localhost:3123"},
121
+ "openmontage": {"cli_path": ""},
122
+ "youtube_upload": {
123
+ "url": "",
124
+ "api_key": "",
125
+ "upload_path": "/api/upload",
126
+ "publicar_path": "/api/videos/{video_id}/publicar",
127
+ "modo": "nao_listado_1h_publico",
128
+ "limite_diario": 50,
129
+ "espera_publicacao_horas": 1,
130
+ "uploader_mcp_path": "",
131
+ },
132
+ "openoutreach": {"url": "", "send_path": "/api/send"},
133
+ "pipecat": {"url": ""},
134
+ },
135
+ "extratores": {
136
+ "google_maps_cli": "",
137
+ "google_maps_args": (
138
+ '--kw "{palavra_chave}" --loc "{localizacao}" --lang pt --email --max {max_resultados} --outdir "{outdir}"'
139
+ ),
140
+ "igmapper_cli": "",
141
+ "igmapper_args": "--profile {url} --outdir {outdir}",
142
+ "scrapling_cli": "",
143
+ "cnpj_provedor_busca": "https://api.opencnpj.org",
144
+ },
145
+ "agentes": {
146
+ "disparo": {
147
+ "templates": {
148
+ "texto": (
149
+ "Olá {{primeiro_nome}}! Sou da {{minha_empresa}} e ajudo empresas como "
150
+ "{{empresa}} a crescerem com conteúdo automatizado no YouTube. "
151
+ "Posso te enviar uma proposta rápida?"
152
+ ),
153
+ "audio": "Olá {{primeiro_nome}}, tudo bem? Falo em nome da {{minha_empresa}}...",
154
+ },
155
+ "minha_empresa": "LeadMaster",
156
+ "delay_entre_envios_segundos": 3,
157
+ },
158
+ "atendimento": {"modo": "texto"},
159
+ },
160
+ "webhook": {
161
+ "url": "",
162
+ "eventos": ["lead.criado", "lead.etapa", "campanha.concluida", "video.publicado"],
163
+ },
164
+ "uploads": {"data": "", "contador": 0},
165
+ }
166
+
167
+
168
+ def _deep_merge(base: dict, override: dict) -> dict:
169
+ out = dict(base)
170
+ for key, value in override.items():
171
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
172
+ out[key] = _deep_merge(out[key], value)
173
+ else:
174
+ out[key] = value
175
+ return out
176
+
177
+
178
+ def _deep_merge_preserve_secrets(base: dict, override: dict) -> dict:
179
+ """Deep merge que ignora valores vazios em campos de segredo (manter atual)."""
180
+ out = dict(base)
181
+ for key, value in override.items():
182
+ if key in SECRET_KEYS and (value == "" or value is None) and key in out:
183
+ continue
184
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
185
+ out[key] = _deep_merge_preserve_secrets(out[key], value)
186
+ else:
187
+ out[key] = value
188
+ return out
189
+
190
+
191
+ def get_config() -> dict:
192
+ return _deep_merge(DEFAULT_CONFIG, _read(CONFIG_FILE, {}))
193
+
194
+
195
+ def update_config(patch: dict) -> dict:
196
+ with LOCK:
197
+ atual = get_config()
198
+ novo = _deep_merge_preserve_secrets(atual, patch)
199
+ _write(CONFIG_FILE, novo)
200
+ return novo
201
+
202
+
203
+ def redact_config(config: dict) -> dict:
204
+ """Copia a config substituindo segredos por vazio + flag `_configurado`."""
205
+ out = {}
206
+ for key, value in config.items():
207
+ if isinstance(value, dict):
208
+ out[key] = redact_config(value)
209
+ elif key in SECRET_KEYS:
210
+ out[key] = ""
211
+ out[f"{key}_configurado"] = bool(value)
212
+ else:
213
+ out[key] = value
214
+ return out
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Leads
219
+ # ---------------------------------------------------------------------------
220
+
221
+ def list_leads() -> list:
222
+ return _read(LEADS_FILE, [])
223
+
224
+
225
+ def save_leads(leads: list) -> None:
226
+ _write(LEADS_FILE, leads)
227
+
228
+
229
+ def add_lead(lead: dict) -> dict:
230
+ with LOCK:
231
+ leads = list_leads()
232
+ lead["criado_em"] = lead.get("criado_em") or now_iso()
233
+ lead["atualizado_em"] = now_iso()
234
+ leads.append(lead)
235
+ save_leads(leads)
236
+ return lead
237
+
238
+
239
+ def update_lead(lead_id: str, patch: dict) -> dict | None:
240
+ with LOCK:
241
+ leads = list_leads()
242
+ for lead in leads:
243
+ if lead.get("id") == lead_id:
244
+ lead.update(patch)
245
+ lead["atualizado_em"] = now_iso()
246
+ save_leads(leads)
247
+ return lead
248
+ return None
249
+
250
+
251
+ def delete_lead(lead_id: str) -> bool:
252
+ with LOCK:
253
+ leads = list_leads()
254
+ restantes = [l for l in leads if l.get("id") != lead_id]
255
+ if len(restantes) == len(leads):
256
+ return False
257
+ save_leads(restantes)
258
+ return True
259
+
260
+
261
+ def import_leads(novos: list, dedup: bool = True) -> dict:
262
+ """Importa leads em massa; retorna {importados, duplicados}."""
263
+ with LOCK:
264
+ leads = list_leads()
265
+ existentes = {
266
+ (l.get("telefone") or "").strip(),
267
+ (l.get("email") or "").strip().lower(),
268
+ }
269
+ importados, duplicados = [], []
270
+ for novo in novos:
271
+ tel = (novo.get("telefone") or "").strip()
272
+ email = (novo.get("email") or "").strip().lower()
273
+ if dedup and ((tel and tel in existentes) or (email and email in existentes)):
274
+ duplicados.append(novo)
275
+ continue
276
+ novo.setdefault("id", _novo_id())
277
+ novo["criado_em"] = now_iso()
278
+ novo["atualizado_em"] = now_iso()
279
+ leads.append(novo)
280
+ existentes.add(tel)
281
+ existentes.add(email)
282
+ importados.append(novo)
283
+ save_leads(leads)
284
+ return {"importados": importados, "duplicados": duplicados}
285
+
286
+
287
+ def _novo_id() -> str:
288
+ import uuid
289
+
290
+ return uuid.uuid4().hex[:12]
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Campanhas
295
+ # ---------------------------------------------------------------------------
296
+
297
+ def list_campaigns() -> list:
298
+ return _read(CAMPAIGNS_FILE, [])
299
+
300
+
301
+ def save_campaigns(campaigns: list) -> None:
302
+ _write(CAMPAIGNS_FILE, campaigns)
303
+
304
+
305
+ def upsert_campaign(campaign: dict) -> dict:
306
+ with LOCK:
307
+ campaigns = list_campaigns()
308
+ for i, c in enumerate(campaigns):
309
+ if c.get("id") == campaign["id"]:
310
+ campaigns[i] = campaign
311
+ break
312
+ else:
313
+ campaigns.append(campaign)
314
+ save_campaigns(campaigns)
315
+ return campaign
316
+
317
+
318
+ def get_campaign(campaign_id: str) -> dict | None:
319
+ for c in list_campaigns():
320
+ if c.get("id") == campaign_id:
321
+ return c
322
+ return None
323
+
324
+
325
+ def delete_campaign(campaign_id: str) -> bool:
326
+ with LOCK:
327
+ campaigns = list_campaigns()
328
+ restantes = [c for c in campaigns if c.get("id") != campaign_id]
329
+ if len(restantes) == len(campaigns):
330
+ return False
331
+ save_campaigns(restantes)
332
+ return True
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Pipeline
337
+ # ---------------------------------------------------------------------------
338
+
339
+ def list_jobs() -> list:
340
+ data = _read(PIPELINE_FILE, {"jobs": []})
341
+ if isinstance(data, list): # tolerância a formato antigo
342
+ data = {"jobs": data}
343
+ return data.get("jobs", [])
344
+
345
+
346
+ def save_jobs(jobs: list) -> None:
347
+ _write(PIPELINE_FILE, {"jobs": jobs})
348
+
349
+
350
+ def get_job(job_id: str) -> dict | None:
351
+ for j in list_jobs():
352
+ if j.get("id") == job_id:
353
+ return j
354
+ return None
355
+
356
+
357
+ def upsert_job(job: dict) -> dict:
358
+ with LOCK:
359
+ jobs = list_jobs()
360
+ for i, j in enumerate(jobs):
361
+ if j.get("id") == job["id"]:
362
+ jobs[i] = job
363
+ break
364
+ else:
365
+ jobs.append(job)
366
+ save_jobs(jobs)
367
+ return job
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Seeds (primeira execução)
372
+ # ---------------------------------------------------------------------------
373
+
374
+ def _seed_leads() -> list:
375
+ """Leads de demonstração para o painel nascer com vida."""
376
+ modelos = [
377
+ ("Construtora Horizonte Ltda", "(11) 3344-5566", "contato@horizonte.com.br", "CNPJ", "leads", "Construtora Horizonte", "São Paulo/SP", 62),
378
+ ("Clínica Bem Viver", "(11) 98877-1122", "contato@bemviver.com.br", "Google Maps", "leads", "Clínica Bem Viver", "Campinas/SP", 48),
379
+ ("Padaria do Largo", "(21) 2233-4455", "", "Google Maps", "leads", "Padaria do Largo", "Rio de Janeiro/RJ", 25),
380
+ ("Academia Iron Fit", "(31) 99876-5544", "atendimento@ironfit.com.br", "Instagram", "leads", "Iron Fit", "Belo Horizonte/MG", 55),
381
+ ("Escritório Delta Contábil", "(41) 3322-7788", "delta@deltacontabil.com.br", "CNPJ", "em_contato", "Delta Contábil", "Curitiba/PR", 74),
382
+ ("Restaurante Sabor Caseiro", "(85) 98811-2233", "saborcaseiro@gmail.com", "Google Maps", "em_contato", "Sabor Caseiro", "Fortaleza/CE", 68),
383
+ ("Ótica Visão Clara", "(51) 3445-6677", "contato@visaoclara.com.br", "Instagram", "proposta", "Visão Clara", "Porto Alegre/RS", 88),
384
+ ("Mercado Central Atacado", "(62) 99555-1010", "compras@centralatacado.com.br", "CNPJ", "fechado", "Central Atacado", "Goiânia/GO", 95),
385
+ ]
386
+ leads = []
387
+ for nome, telefone, email, fonte, etapa, empresa, cidade, score in modelos:
388
+ leads.append(
389
+ {
390
+ "id": _novo_id(),
391
+ "nome": nome,
392
+ "telefone": telefone,
393
+ "email": email,
394
+ "fonte": fonte,
395
+ "etapa": etapa,
396
+ "empresa": empresa,
397
+ "cidade": cidade,
398
+ "score": score,
399
+ "tags": [],
400
+ "observacoes": "",
401
+ "criado_em": now_iso(),
402
+ "atualizado_em": now_iso(),
403
+ }
404
+ )
405
+ return leads
406
+
407
+
408
+ def _seed_campaigns() -> list:
409
+ return [
410
+ {
411
+ "id": _novo_id(),
412
+ "nome": "Reativação Q4 (demonstração)",
413
+ "canal": "texto",
414
+ "template": "texto",
415
+ "lead_ids": [],
416
+ "status": "concluida",
417
+ "total": 50,
418
+ "enviadas": 47,
419
+ "erros": 3,
420
+ "criado_em": now_iso(),
421
+ "iniciada_em": now_iso(),
422
+ "concluida_em": now_iso(),
423
+ "eventos": [],
424
+ },
425
+ {
426
+ "id": _novo_id(),
427
+ "nome": "Boas-vindas Novos Leads (demonstração)",
428
+ "canal": "texto",
429
+ "template": "texto",
430
+ "lead_ids": [],
431
+ "status": "rascunho",
432
+ "total": 0,
433
+ "enviadas": 0,
434
+ "erros": 0,
435
+ "criado_em": now_iso(),
436
+ "eventos": [],
437
+ },
438
+ ]
439
+
440
+
441
+ def ensure_seeds() -> None:
442
+ """Cria os arquivos de dados com seeds na primeira execução."""
443
+ with LOCK:
444
+ d = storage_dir()
445
+ if not (d / LEADS_FILE).exists():
446
+ _write(LEADS_FILE, _seed_leads())
447
+ if not (d / CAMPAIGNS_FILE).exists():
448
+ _write(CAMPAIGNS_FILE, _seed_campaigns())
449
+ if not (d / PIPELINE_FILE).exists():
450
+ _write(PIPELINE_FILE, {"jobs": []})
451
+ if not (d / CONFIG_FILE).exists():
452
+ _write(CONFIG_FILE, DEFAULT_CONFIG)
453
+
454
+
455
+ def limpar_seeds_demo() -> None:
456
+ """Remove os dados de demonstração (usado quando o usuário pede)."""
457
+ with LOCK:
458
+ save_leads([])
459
+ save_campaigns([])
460
+ save_jobs([])
@@ -0,0 +1,163 @@
1
+ """Upload para YouTube com a estratégia do LeadMaster:
2
+
3
+ 1. Upload primário via serviço local "YouTube-Video-Upload-Frontend-Api"
4
+ (URL e caminhos configuráveis em Ferramentas → Upload YouTube).
5
+ 2. Fallback opcional via CLI do `youtube-uploader-mcp`.
6
+ 3. Publicação como **não listado**, aguardando 1h e mudando para **público**
7
+ (agendamento feito pelo motor do pipeline).
8
+ 4. Limite diário de 50 uploads (contagem local por data).
9
+
10
+ Erros são sempre acionáveis: como instalar/configurar cada peça.
11
+ """
12
+
13
+ import subprocess
14
+ from datetime import date
15
+
16
+ import httpx
17
+
18
+ from . import storage
19
+
20
+
21
+ class UploadNaoConfigurado(Exception):
22
+ pass
23
+
24
+
25
+ class LimiteDiarioExcedido(Exception):
26
+ pass
27
+
28
+
29
+ class UploadErro(Exception):
30
+ pass
31
+
32
+
33
+ def _conf() -> dict:
34
+ return storage.get_config()["integracoes"]["youtube_upload"]
35
+
36
+
37
+ def _contagem() -> dict:
38
+ return storage.get_config()["uploads"]
39
+
40
+
41
+ def verificar() -> bool:
42
+ conf = _conf()
43
+ if not conf.get("url"):
44
+ return bool(conf.get("uploader_mcp_path"))
45
+ try:
46
+ httpx.get(conf["url"].rstrip("/"), timeout=2)
47
+ return True
48
+ except httpx.HTTPError:
49
+ return False
50
+
51
+
52
+ def contagem_hoje() -> int:
53
+ c = _contagem()
54
+ hoje = date.today().isoformat()
55
+ return c["contador"] if c.get("data") == hoje else 0
56
+
57
+
58
+ def _incrementar_contagem() -> None:
59
+ config = storage.get_config()
60
+ hoje = date.today().isoformat()
61
+ uploads = config.setdefault("uploads", {"data": hoje, "contador": 0})
62
+ if uploads.get("data") != hoje:
63
+ uploads.update({"data": hoje, "contador": 0})
64
+ uploads["contador"] = int(uploads.get("contador", 0)) + 1
65
+ storage.update_config({"uploads": uploads})
66
+
67
+
68
+ def upload_video(titulo: str, descricao: str, arquivo: str = "", video_url: str = "", tags: str = "") -> dict:
69
+ """Faz o upload como não listado. Retorna {video_id, url, modo, aviso}."""
70
+ conf = _conf()
71
+
72
+ limite = int(conf.get("limite_diario", 50))
73
+ if contagem_hoje() >= limite:
74
+ raise LimiteDiarioExcedido(
75
+ f"Limite diário de {limite} uploads atingido (hoje: {contagem_hoje()}). "
76
+ "O upload continua amanhã automaticamente — clique Repetir no job."
77
+ )
78
+
79
+ if conf.get("url"):
80
+ resultado = _upload_via_servico(conf, titulo, descricao, arquivo, video_url, tags)
81
+ elif conf.get("uploader_mcp_path"):
82
+ resultado = _upload_via_mcp(conf, titulo, descricao, arquivo, video_url)
83
+ else:
84
+ raise UploadNaoConfigurado(
85
+ "Nenhum uploader configurado. Em Ferramentas → Upload YouTube, informe a URL do "
86
+ "YouTube-Video-Upload-Frontend-Api (primário) ou o caminho do youtube-uploader-mcp (fallback)."
87
+ )
88
+ _incrementar_contagem()
89
+ return resultado
90
+
91
+
92
+ def _upload_via_servico(conf: dict, titulo: str, descricao: str, arquivo: str, video_url: str, tags: str) -> dict:
93
+ base = conf["url"].rstrip("/")
94
+ path = conf.get("upload_path", "/api/upload").lstrip("/")
95
+ payload = {
96
+ "title": titulo,
97
+ "description": descricao,
98
+ "tags": tags,
99
+ "privacy": "unlisted", # não listado → agenda de 1h → público
100
+ "video_url": video_url,
101
+ "video_file": arquivo,
102
+ "api_key": conf.get("api_key", ""),
103
+ }
104
+ try:
105
+ r = httpx.post(f"{base}/{path}", json=payload, timeout=600)
106
+ except httpx.HTTPError as e:
107
+ raise UploadErro(
108
+ f"Uploader local ({base}) não respondeu: {e}. Confirme que o "
109
+ "YouTube-Video-Upload-Frontend-Api está ativo e clique Repetir."
110
+ ) from e
111
+ if r.status_code >= 400:
112
+ raise UploadErro(f"Uploader local rejeitou o vídeo (HTTP {r.status_code}): {r.text[:300]}")
113
+ try:
114
+ corpo = r.json()
115
+ except ValueError:
116
+ corpo = {"resposta_bruta": r.text[:300]}
117
+ video_id = corpo.get("video_id") or corpo.get("videoId") or corpo.get("id") or ""
118
+ return {
119
+ "video_id": video_id,
120
+ "url": corpo.get("url") or (f"https://youtu.be/{video_id}" if video_id else ""),
121
+ "modo": "servico_local",
122
+ "resposta": corpo,
123
+ }
124
+
125
+
126
+ def _upload_via_mcp(conf: dict, titulo: str, descricao: str, arquivo: str, video_url: str) -> dict:
127
+ cli = conf.get("uploader_mcp_path", "")
128
+ if not arquivo:
129
+ raise UploadErro("O fallback youtube-uploader-mcp precisa do arquivo de vídeo local (produza o vídeo primeiro).")
130
+ try:
131
+ resultado = subprocess.run(
132
+ [cli, arquivo, "--title", titulo, "--description", descricao, "--privacy", "unlisted"],
133
+ capture_output=True,
134
+ text=True,
135
+ timeout=3600,
136
+ shell=False,
137
+ )
138
+ except FileNotFoundError as e:
139
+ raise UploadNaoConfigurado(f"Caminho do youtube-uploader-mcp inválido: {e}") from e
140
+ if resultado.returncode != 0:
141
+ raise UploadErro(f"youtube-uploader-mcp falhou: {resultado.stderr[-400:]}")
142
+ return {
143
+ "video_id": resultado.stdout.strip().splitlines()[-1] if resultado.stdout.strip() else "",
144
+ "url": resultado.stdout.strip().splitlines()[-1] if resultado.stdout.strip() else "",
145
+ "modo": "uploader_mcp",
146
+ "resposta": {"stdout": resultado.stdout[-500:]},
147
+ }
148
+
149
+
150
+ def tornar_publico(video_id: str) -> dict:
151
+ """Muda o vídeo de não listado para público (chamado pelo agendador)."""
152
+ conf = _conf()
153
+ if not conf.get("url"):
154
+ raise UploadNaoConfigurado("Uploader local não configurado; publique manualmente o vídeo " f"{video_id}.")
155
+ base = conf["url"].rstrip("/")
156
+ path = conf.get("publicar_path", "/api/videos/{video_id}/publicar").lstrip("/")
157
+ try:
158
+ r = httpx.post(f"{base}/{path.format(video_id=video_id)}", json={"privacy": "public"}, timeout=60)
159
+ except httpx.HTTPError as e:
160
+ raise UploadErro(f"Falha ao publicar {video_id} via uploader local: {e}") from e
161
+ if r.status_code >= 400:
162
+ raise UploadErro(f"Uploader local recusou publicar {video_id} (HTTP {r.status_code}): {r.text[:300]}")
163
+ return {"ok": True, "resposta": r.text[:300]}