chore: auto-commit (43 archivos)
- .mcp.json - bash/functions/infra/write_mcp_jupyter_config.md - bash/functions/infra/write_mcp_jupyter_config.sh - cpp/CMakeLists.txt - cpp/apps/chart_demo - cpp/apps/shaders_lab - cpp/functions/gfx/gl_framebuffer.cpp - cpp/functions/gfx/gl_framebuffer.h - cpp/functions/gfx/gl_framebuffer.md - cpp/functions/gfx/mesh_gpu.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: clickhouse_insert_rows
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def clickhouse_insert_rows(base_url: str, table: str, rows: list[dict], *, user: str = 'default', password: str = '', database: str = 'analytics', timeout: float = 30.0) -> int"
|
||||
description: "Inserta una lista de dicts en ClickHouse via HTTP (puerto 8123) usando el formato JSONEachRow. Retorna el numero de filas enviadas."
|
||||
tags: [clickhouse, analytics, http, insert, ingest, etl]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, urllib.request, urllib.parse, urllib.error]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/clickhouse_insert_rows.py"
|
||||
params:
|
||||
- name: base_url
|
||||
desc: "URL base del servidor ClickHouse sin trailing slash. Ej: 'http://127.0.0.1:18123'. Para tunel SSH, apunta al puerto local reenviado."
|
||||
- name: table
|
||||
desc: "Nombre de la tabla destino, con o sin prefijo de base de datos. Ej: 'analytics.gnula_movies' o 'gnula_movies'."
|
||||
- name: rows
|
||||
desc: "Lista de dicts a insertar. Cada dict se serializa como una linea JSON. Las claves deben coincidir con columnas existentes; columnas ausentes usan DEFAULT."
|
||||
- name: user
|
||||
desc: "Usuario ClickHouse para autenticacion via header X-ClickHouse-User (default: 'default')."
|
||||
- name: password
|
||||
desc: "Contrasena ClickHouse para autenticacion via header X-ClickHouse-Key (default: cadena vacia)."
|
||||
- name: database
|
||||
desc: "Base de datos ClickHouse enviada como parametro de query (default: 'analytics')."
|
||||
- name: timeout
|
||||
desc: "Timeout de socket en segundos (default: 30.0)."
|
||||
output: "Entero con el numero de filas insertadas (len(rows)). Retorna 0 si rows esta vacio sin contactar el servidor."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra import clickhouse_insert_rows
|
||||
|
||||
n = clickhouse_insert_rows(
|
||||
"http://127.0.0.1:18123",
|
||||
"analytics.gnula_movies",
|
||||
[
|
||||
{
|
||||
"snapshot_ts": "2026-05-30 14:00:00",
|
||||
"href": "/pelicula/avatar-el-camino-del-agua",
|
||||
"title": "Avatar: El camino del agua",
|
||||
"year": 2022,
|
||||
"flags": "es.png",
|
||||
"lang_es": 1,
|
||||
"status": "pending",
|
||||
"in_library": 0,
|
||||
"detected_at": "2026-05-30T14:00:00",
|
||||
"downloaded_at": "",
|
||||
}
|
||||
],
|
||||
user="analytics",
|
||||
password="secret",
|
||||
database="analytics",
|
||||
)
|
||||
print(f"Inserted {n} rows")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un ETL empuja snapshots o eventos a ClickHouse via HTTP (puerto 8123), incluyendo a traves de un tunel SSH a un ClickHouse interno no expuesto publicamente. Alternativa ligera (solo stdlib) a `clickhouse-driver` o `clickhouse-connect` cuando no se quieren dependencias externas.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `base_url` sin trailing slash: `"http://127.0.0.1:18123"`, no `"http://127.0.0.1:18123/"`.
|
||||
- Fechas y datetimes deben pasarse como strings en formato que ClickHouse acepte (`"YYYY-MM-DD HH:MM:SS"`) o como enteros epoch. El caller formatea; esta funcion no convierte tipos.
|
||||
- Arrays van como listas JSON nativas Python: `{"tags": ["drama", "sci-fi"]}`.
|
||||
- Columnas ausentes en un dict usan el valor DEFAULT de la tabla (JSONEachRow ignora claves faltantes). No falla.
|
||||
- Para tunel SSH: `ssh -L 18123:localhost:8123 user@host` y usar `base_url="http://127.0.0.1:18123"`.
|
||||
- En caso de error HTTP, `ValueError` incluye el codigo y los primeros 500 caracteres del cuerpo — util para depurar errores de schema o SQL malformado.
|
||||
- Lotes grandes: no hay batching interno. Si `rows` tiene miles de elementos, el body puede ser grande. Partir en chunks desde el caller si es necesario.
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Insert rows into ClickHouse via the HTTP interface (port 8123)."""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def clickhouse_insert_rows(
|
||||
base_url: str,
|
||||
table: str,
|
||||
rows: list[dict],
|
||||
*,
|
||||
user: str = "default",
|
||||
password: str = "",
|
||||
database: str = "analytics",
|
||||
timeout: float = 30.0,
|
||||
) -> int:
|
||||
"""Insert a list of dicts into a ClickHouse table using JSONEachRow format.
|
||||
|
||||
Args:
|
||||
base_url: ClickHouse HTTP base URL without trailing slash,
|
||||
e.g. "http://127.0.0.1:18123".
|
||||
table: Fully-qualified or bare table name, e.g. "analytics.gnula_movies".
|
||||
rows: List of dicts to insert. Each dict becomes one JSON line.
|
||||
user: ClickHouse username (default "default").
|
||||
password: ClickHouse password (default empty string).
|
||||
database: Target database sent as query param (default "analytics").
|
||||
timeout: Socket timeout in seconds (default 30.0).
|
||||
|
||||
Returns:
|
||||
Number of rows inserted (len(rows)). Returns 0 if rows is empty
|
||||
without contacting the server.
|
||||
|
||||
Raises:
|
||||
ValueError: On non-200 HTTP response, with status code and first
|
||||
500 chars of the response body.
|
||||
urllib.error.URLError: On network-level errors (connection refused,
|
||||
DNS failure, timeout).
|
||||
"""
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
query = f"INSERT INTO {table} FORMAT JSONEachRow"
|
||||
params = urllib.parse.urlencode({"database": database, "query": query})
|
||||
url = f"{base_url}/?{params}"
|
||||
|
||||
body = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows)
|
||||
body_bytes = body.encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body_bytes,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "text/plain",
|
||||
"X-ClickHouse-User": user,
|
||||
"X-ClickHouse-Key": password,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
if resp.status != 200:
|
||||
body_preview = resp.read(500).decode("utf-8", errors="replace")
|
||||
raise ValueError(
|
||||
f"ClickHouse insert failed: HTTP {resp.status} — {body_preview}"
|
||||
)
|
||||
return len(rows)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body_preview = exc.read(500).decode("utf-8", errors="replace")
|
||||
raise ValueError(
|
||||
f"ClickHouse insert failed: HTTP {exc.code} — {body_preview}"
|
||||
) from exc
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: clickhouse_query
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def clickhouse_query(base_url: str, sql: str, *, user: str = 'default', password: str = '', database: str = 'analytics', timeout: float = 30.0) -> list[dict]"
|
||||
description: "Ejecuta un SQL contra ClickHouse via HTTP (puerto 8123) y retorna los resultados como lista de dicts. Para SELECT usa JSONEachRow automaticamente; para DDL/DML retorna []."
|
||||
tags: [clickhouse, analytics, http, query, select, read]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, urllib.request, urllib.parse, urllib.error]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/clickhouse_query.py"
|
||||
params:
|
||||
- name: base_url
|
||||
desc: "URL base del servidor ClickHouse sin trailing slash. Ej: 'http://127.0.0.1:18123'. Para tunel SSH, apunta al puerto local reenviado."
|
||||
- name: sql
|
||||
desc: "SQL completo a ejecutar. El caller escribe el SQL entero; esta funcion no anade nada. Para SELECT retorna filas; para CREATE/INSERT/etc. retorna []."
|
||||
- name: user
|
||||
desc: "Usuario ClickHouse para autenticacion via header X-ClickHouse-User (default: 'default')."
|
||||
- name: password
|
||||
desc: "Contrasena ClickHouse para autenticacion via header X-ClickHouse-Key (default: cadena vacia)."
|
||||
- name: database
|
||||
desc: "Base de datos ClickHouse enviada como parametro de query (default: 'analytics')."
|
||||
- name: timeout
|
||||
desc: "Timeout de socket en segundos (default: 30.0)."
|
||||
output: "Lista de dicts, uno por fila del resultado. Lista vacia para sentencias sin resultado (DDL, INSERT). Los numeros Int64 de ClickHouse llegan como strings JSON — castear con int() si se necesita aritmetica."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra import clickhouse_query
|
||||
|
||||
# Contar filas
|
||||
rows = clickhouse_query(
|
||||
"http://127.0.0.1:18123",
|
||||
"SELECT count() AS c FROM analytics.gnula_movies",
|
||||
user="analytics",
|
||||
password="secret",
|
||||
database="analytics",
|
||||
)
|
||||
print(int(rows[0]["c"])) # Int64 llega como string → castear
|
||||
|
||||
# Leer ultimas inserciones
|
||||
recent = clickhouse_query(
|
||||
"http://127.0.0.1:18123",
|
||||
"SELECT snapshot_ts, title, status FROM analytics.gnula_movies ORDER BY snapshot_ts DESC LIMIT 5",
|
||||
user="analytics",
|
||||
password="secret",
|
||||
)
|
||||
for r in recent:
|
||||
print(r["snapshot_ts"], r["title"], r["status"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Leer agregados, validar ingest o inspeccionar datos en ClickHouse via HTTP sin dependencias externas. Util en ETLs de validacion, notebooks, scripts de monitoreo y pipelines que ya usan `clickhouse_insert_rows_py_infra` para escribir y necesitan verificar el resultado. Tambien sirve para ejecutar DDL (CREATE TABLE, etc.) cuando la respuesta vacia es esperada.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `base_url` sin trailing slash: `"http://127.0.0.1:18123"`, no `"http://127.0.0.1:18123/"`.
|
||||
- El caller escribe el SQL completo. Esta funcion no anade FORMAT, LIMIT ni nada — lo que se pasa es lo que se ejecuta.
|
||||
- **Int64 y UInt64 de ClickHouse llegan como strings JSON** en JSONEachRow. Castear explicitamente: `int(row["c"])`. Float64 llega como numero JSON nativo.
|
||||
- Para SELECT sin FORMAT explicito, `default_format=JSONEachRow` se aplica automaticamente via query param. No hace falta escribir `FORMAT JSONEachRow` en el SQL.
|
||||
- DDL y DML (CREATE, INSERT, ALTER) retornan cuerpo vacio → la funcion retorna `[]`. No es un error.
|
||||
- Para tunel SSH: `ssh -L 18123:localhost:8123 user@host` y usar `base_url="http://127.0.0.1:18123"`.
|
||||
- En caso de error HTTP, `ValueError` incluye el codigo y los primeros 500 caracteres del cuerpo — util para depurar errores de SQL o permisos.
|
||||
- Queries con resultado muy grande se leen enteras en memoria. Para resultados masivos, usar LIMIT o streaming manual con `urllib.request.urlopen` directamente.
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Query ClickHouse via the HTTP interface (port 8123) and return rows as dicts."""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def clickhouse_query(
|
||||
base_url: str,
|
||||
sql: str,
|
||||
*,
|
||||
user: str = "default",
|
||||
password: str = "",
|
||||
database: str = "analytics",
|
||||
timeout: float = 30.0,
|
||||
) -> list[dict]:
|
||||
"""Execute a SQL statement against ClickHouse via HTTP and return results.
|
||||
|
||||
Args:
|
||||
base_url: ClickHouse HTTP base URL without trailing slash,
|
||||
e.g. "http://127.0.0.1:18123".
|
||||
sql: Full SQL statement. For SELECT queries the server returns
|
||||
JSONEachRow automatically via the default_format param.
|
||||
For DDL/DML with no result set (CREATE, INSERT, etc.)
|
||||
the response body is empty and [] is returned.
|
||||
user: ClickHouse username (default "default").
|
||||
password: ClickHouse password (default empty string).
|
||||
database: Target database sent as query param (default "analytics").
|
||||
timeout: Socket timeout in seconds (default 30.0).
|
||||
|
||||
Returns:
|
||||
List of dicts, one per result row. Empty list for statements that
|
||||
produce no result set. Numbers may come back as strings for some
|
||||
ClickHouse types (e.g. Int64 is returned as a JSON string in
|
||||
JSONEachRow — cast explicitly if needed: int(row["c"])).
|
||||
|
||||
Raises:
|
||||
ValueError: On non-200 HTTP response, with status code and first
|
||||
500 chars of the response body.
|
||||
urllib.error.URLError: On network-level errors (connection refused,
|
||||
DNS failure, timeout).
|
||||
"""
|
||||
params = urllib.parse.urlencode(
|
||||
{"database": database, "default_format": "JSONEachRow"}
|
||||
)
|
||||
url = f"{base_url}/?{params}"
|
||||
|
||||
body_bytes = sql.encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body_bytes,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "text/plain",
|
||||
"X-ClickHouse-User": user,
|
||||
"X-ClickHouse-Key": password,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
if resp.status != 200:
|
||||
body_preview = resp.read(500).decode("utf-8", errors="replace")
|
||||
raise ValueError(
|
||||
f"ClickHouse query failed: HTTP {resp.status} — {body_preview}"
|
||||
)
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body_preview = exc.read(500).decode("utf-8", errors="replace")
|
||||
raise ValueError(
|
||||
f"ClickHouse query failed: HTTP {exc.code} — {body_preview}"
|
||||
) from exc
|
||||
|
||||
rows = []
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
rows.append(json.loads(line))
|
||||
return rows
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: popelis_create_user
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def popelis_create_user(base_url: str, admin_token: str, username: str, password: str, timeout: float = 30.0) -> dict"
|
||||
description: "Crea un usuario en la API de administracion de Popelis (POST /api/admin/users). El backend crea automaticamente un usuario Jellyfin espejo (Modelo B). Registro CERRADO: requiere cabecera X-Admin-Token."
|
||||
tags: [popelis, http, admin, user, jellyfin, infra]
|
||||
params:
|
||||
- name: base_url
|
||||
desc: "URL base del servicio sin trailing slash. Ej: https://popelis.datardos.com"
|
||||
- name: admin_token
|
||||
desc: "Token de administracion. Se envia como cabecera X-Admin-Token. No logear ni exponer."
|
||||
- name: username
|
||||
desc: "Nombre de usuario a crear. Debe ser unico en el sistema."
|
||||
- name: password
|
||||
desc: "Contrasena inicial del nuevo usuario."
|
||||
- name: timeout
|
||||
desc: "Timeout en segundos para la peticion HTTP. Default 30.0."
|
||||
output: "Dict con los datos del usuario creado: {id: N, username: str, jfUserId: str}"
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json", "urllib.request", "urllib.error"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/popelis_create_user.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra.popelis_create_user import popelis_create_user
|
||||
|
||||
result = popelis_create_user(
|
||||
base_url="https://popelis.datardos.com",
|
||||
admin_token="<admin-token>",
|
||||
username="alice",
|
||||
password="s3cur3pass",
|
||||
)
|
||||
# result == {"id": 42, "username": "alice", "jfUserId": "abc123-..."}
|
||||
print(result)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites dar de alta un usuario nuevo en Popelis desde un script de administracion, pipeline de onboarding o agente. Usar ANTES de `popelis_set_password` (que requiere que el usuario ya exista).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Token sensible**: `admin_token` es un secreto. Nunca logear el valor, interpolarlo en URLs ni persistirlo en texto plano.
|
||||
- **Registro cerrado**: el endpoint rechaza cualquier peticion sin `X-Admin-Token` valido (HTTP 401/403).
|
||||
- **409 si ya existe**: si el `username` ya esta registrado, el servidor devuelve HTTP 409 y la funcion lanza `ValueError`. Para idempotencia, captura el error y comprueba `"409"` en el mensaje o verifica existencia previa.
|
||||
- **Jellyfin espejo**: el backend crea automaticamente un usuario Jellyfin con el mismo `username`. Si Jellyfin no esta disponible en el momento de la llamada, la creacion puede fallar en el backend (el error llega como 5xx).
|
||||
- **Solo stdlib**: no requiere `requests` ni dependencias externas — usa `urllib.request`.
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Crea un usuario en la API de administracion de Popelis."""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def popelis_create_user(
|
||||
base_url: str,
|
||||
admin_token: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""Crea un usuario en Popelis via la API de administracion.
|
||||
|
||||
Hace POST a {base_url}/api/admin/users con el token de admin y las
|
||||
credenciales del nuevo usuario. El backend crea ademas automaticamente
|
||||
un usuario Jellyfin espejo (Modelo B).
|
||||
|
||||
Args:
|
||||
base_url: URL base del servicio, sin trailing slash.
|
||||
Ej: "https://popelis.datardos.com"
|
||||
admin_token: Token de administracion (cabecera X-Admin-Token).
|
||||
Mantenerlo en secreto — no logear.
|
||||
username: Nombre de usuario a crear. Debe ser unico.
|
||||
password: Contrasena inicial del usuario.
|
||||
timeout: Timeout en segundos para la peticion HTTP. Default 30.0.
|
||||
|
||||
Returns:
|
||||
Dict con los datos del usuario creado:
|
||||
{"id": N, "username": "...", "jfUserId": "..."}
|
||||
|
||||
Raises:
|
||||
ValueError: Si el servidor devuelve 4xx o 5xx. El mensaje incluye
|
||||
el status code y el campo "error" del body JSON si existe.
|
||||
urllib.error.URLError: Si no se puede conectar al servidor.
|
||||
"""
|
||||
url = f"{base_url.rstrip('/')}/api/admin/users"
|
||||
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Admin-Token": admin_token,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read()
|
||||
return json.loads(body)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
try:
|
||||
detail = json.loads(raw).get("error", raw.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
detail = raw.decode("utf-8", errors="replace")
|
||||
raise ValueError(
|
||||
f"popelis_create_user: HTTP {exc.code} — {detail}"
|
||||
) from exc
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: popelis_import_media_drop
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
description: "Importa drops de Popelis (manual/movies, manual/tv) via Radarr/Sonarr: identifica contra TMDb/TVDB, da de alta peli/serie con metadata y mueve+renombra a la libreria limpia que ve Jellyfin."
|
||||
signature: "def popelis_import_media_drop(radarr_url: str, radarr_key: str, sonarr_url: str, sonarr_key: str, movies_drop: str = '/data/manual/movies', tv_drop: str = '/data/manual/tv', movie_root: str = '/data/media/movies', tv_root: str = '/data/media/tv', quality_profile_id: int = 4, import_mode: str = 'move', dry_run: bool = False, series_refresh_wait: float = 6.0, timeout: float = 60.0) -> dict"
|
||||
error_type: error_go_core
|
||||
returns_optional: false
|
||||
tags: [popelis, radarr, sonarr, jellyfin, media, import, mediastack, http, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
imports: [json, re, time, urllib]
|
||||
tested: false
|
||||
file_path: python/functions/infra/popelis_import_media_drop.py
|
||||
params:
|
||||
- name: radarr_url
|
||||
desc: "URL base de Radarr sin trailing slash. Ej http://localhost:7878"
|
||||
- name: radarr_key
|
||||
desc: "API key de Radarr (Settings > General > Security). Secreto."
|
||||
- name: sonarr_url
|
||||
desc: "URL base de Sonarr. Ej http://localhost:8989"
|
||||
- name: sonarr_key
|
||||
desc: "API key de Sonarr. Secreto."
|
||||
- name: movies_drop
|
||||
desc: "Carpeta drop de pelis EN EL NAMESPACE de Radarr (/data/manual/movies)."
|
||||
- name: tv_drop
|
||||
desc: "Carpeta drop de series EN EL NAMESPACE de Sonarr (/data/manual/tv)."
|
||||
- name: movie_root
|
||||
desc: "Root folder de Radarr donde se mueven las pelis (/data/media/movies)."
|
||||
- name: tv_root
|
||||
desc: "Root folder de Sonarr donde se mueven las series (/data/media/tv)."
|
||||
- name: quality_profile_id
|
||||
desc: "Quality profile id para altas nuevas. 4 = HD-1080p en el stack Popelis."
|
||||
- name: import_mode
|
||||
desc: "'move' (default, borra el origen) o 'copy'."
|
||||
- name: dry_run
|
||||
desc: "Si True no escribe nada: solo reporta que haria. Default False."
|
||||
- name: series_refresh_wait
|
||||
desc: "Segundos a esperar tras dar de alta una serie antes de re-listar el drop (Sonarr refresca episodios async). Default 6.0."
|
||||
- name: timeout
|
||||
desc: "Timeout HTTP por peticion en segundos. Default 60.0."
|
||||
output: "Dict {movies: [...], tv: [...], summary: {...}}: una entrada por fichero/serie con su status (import_queued | added | no_match | still_unmatched | would_*) y contadores agregados en summary."
|
||||
---
|
||||
|
||||
Importa una carpeta drop del stack Popelis (Radarr/Sonarr/Jellyfin) anadiendo
|
||||
metadata. El usuario suelta ficheros sueltos en `manual/movies` o `manual/tv`;
|
||||
esta funcion los identifica contra TMDb/TVDB, da de alta la pelicula/serie con
|
||||
su ficha (poster, fanart, sinopsis) y dispara un ManualImport que mueve+renombra
|
||||
el fichero a la libreria limpia (`media/movies`, `media/tv`). Radarr/Sonarr son
|
||||
la unica fuente de verdad de metadata; Jellyfin solo escanea `media/` y nunca
|
||||
mete fichas fantasma de descargas a medias.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra.popelis_import_media_drop import popelis_import_media_drop
|
||||
|
||||
# Dry-run primero (no escribe nada)
|
||||
res = popelis_import_media_drop(
|
||||
radarr_url="http://localhost:7878",
|
||||
radarr_key="63fb51c8c95746e2a327740baac02f5e",
|
||||
sonarr_url="http://localhost:8989",
|
||||
sonarr_key="1c6f380b1cca49b8b1223570e80f0071",
|
||||
dry_run=True,
|
||||
)
|
||||
print(res["summary"])
|
||||
# {'movies_queued': 1, 'movies_unmatched': 0, 'tv_queued': 0,
|
||||
# 'tv_added_series': 1, 'tv_unmatched': 0, 'dry_run': True}
|
||||
|
||||
# Aplicar de verdad (mueve ficheros + descarga metadata)
|
||||
res = popelis_import_media_drop(
|
||||
radarr_url="http://localhost:7878", radarr_key="...",
|
||||
sonarr_url="http://localhost:8989", sonarr_key="...",
|
||||
dry_run=False,
|
||||
)
|
||||
```
|
||||
|
||||
CLI directo (lee keys de env):
|
||||
|
||||
```bash
|
||||
RADARR_KEY=... SONARR_KEY=... \
|
||||
python/.venv/bin/python3 python/functions/infra/popelis_import_media_drop.py --apply
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando sueltas pelis/series sueltas en `F:/POPELIS/manual/{movies,tv}` y quieres
|
||||
que Radarr/Sonarr las identifiquen, descarguen ficha y las muevan limpias a la
|
||||
libreria que ve Jellyfin.
|
||||
- Antes de mirar Jellyfin: pasa esta funcion para que la libreria solo contenga
|
||||
media identificada (sin fichas fantasma).
|
||||
- Lanzala on-demand tras cada drop. Para automatizar, envuelvela en un watcher/cron.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Paths en namespace de contenedor**: `movies_drop`/`tv_drop`/`movie_root`/`tv_root`
|
||||
son paths DENTRO de Radarr/Sonarr (`/data/...`), NO del host. Radarr y Sonarr
|
||||
montan `F:/POPELIS` en `/data`.
|
||||
- **Permisos del drop (Windows bind)**: las carpetas drop deben ser escribibles
|
||||
por el usuario del contenedor (PUID, abc/1000 en linuxserver). Si las creas como
|
||||
root, `import_mode=move` falla con `UnauthorizedAccessException`. Fix:
|
||||
`docker exec -u 0 radarr chown -R 1000:1000 /data/manual`.
|
||||
- **Numeracion DVD vs aired**: releases en orden DVD (ej. Futurama S01 con 13 eps)
|
||||
chocan con TVDB en orden emision (S01 = 9 eps). Los episodios fuera de rango
|
||||
quedan `still_unmatched` con reason `Invalid season or episode` y permanecen en
|
||||
el drop. No es un bug: requiere resolucion manual o cambiar el series type a
|
||||
'DVD' en Sonarr.
|
||||
- **Lookup coge el primer resultado**: titulos ambiguos pueden matchear mal.
|
||||
Revisa el report antes de confiar; usa `dry_run=True` primero.
|
||||
- **No renombra si la *arr no lo tiene activado**: el fichero se mueve a la carpeta
|
||||
correcta pero conserva su nombre original salvo que actives "Rename" en la config
|
||||
de naming de Radarr/Sonarr.
|
||||
- **import_mode='move' borra el origen**: usa 'copy' si quieres conservar el drop.
|
||||
- **Secretos**: `radarr_key`/`sonarr_key` son API keys. No logear ni interpolar en URLs.
|
||||
- **Solo stdlib**: usa `urllib`, sin `requests`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(sin cambios desde v1.0.0)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Importa una carpeta drop de Popelis via Radarr/Sonarr anadiendo metadata.
|
||||
|
||||
Escanea las carpetas drop (manual/movies, manual/tv), identifica cada
|
||||
fichero contra TMDb/TVDB usando los endpoints de lookup de Radarr/Sonarr,
|
||||
da de alta la pelicula/serie si no existe (descargando metadata: poster,
|
||||
fanart, fichas), y dispara un ManualImport que mueve+renombra el fichero a
|
||||
la libreria limpia (media/movies, media/tv). Las *arr son la unica fuente
|
||||
de verdad de metadata -> Jellyfin solo escanea media/ y nunca mete fichas
|
||||
fantasma de descargas a medias.
|
||||
|
||||
Solo stdlib (urllib). Orquestacion HTTP pura sobre las APIs v3 de
|
||||
Radarr/Sonarr; la funcion NO toca ficheros (lo hace la *arr en su propio
|
||||
namespace de contenedor).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
_VIDEO_RE = re.compile(r"\.(mkv|mp4|avi|m4v|mov|wmv|ts|mpg|mpeg)$", re.I)
|
||||
|
||||
|
||||
def _req(method: str, url: str, key: str, body=None, timeout: float = 60.0):
|
||||
"""HTTP request a una *arr con X-Api-Key. Lanza ValueError en 4xx/5xx."""
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={"X-Api-Key": key, "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:400]
|
||||
raise ValueError(f"{method} {url} -> HTTP {exc.code}: {detail}") from exc
|
||||
|
||||
|
||||
def _title_from_path(rel: str) -> str:
|
||||
"""Deriva un termino de busqueda del path relativo de un drop.
|
||||
|
||||
Usa la carpeta raiz si existe (caso series: 'Futurama/Season 01/...'),
|
||||
si no el nombre del fichero. Limpia separadores, sufijos SxxExx y el
|
||||
anio + lo que venga detras para quedarse con el titulo limpio.
|
||||
"""
|
||||
seg = rel.split("/")[0] if "/" in rel else rel
|
||||
base = _VIDEO_RE.sub("", seg)
|
||||
base = re.sub(r"[._]", " ", base)
|
||||
base = re.sub(r"\bS\d{1,2}E\d{1,2}.*$", "", base, flags=re.I).strip()
|
||||
base = re.sub(r"\b(19|20)\d{2}\b.*$", "", base).strip()
|
||||
return base or seg
|
||||
|
||||
|
||||
def _import_radarr(url, key, drop, root, qpid, mode, dry, timeout):
|
||||
enc = urllib.parse.quote(drop)
|
||||
items = _req(
|
||||
"GET",
|
||||
f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true",
|
||||
key, timeout=timeout,
|
||||
) or []
|
||||
existing = {m["tmdbId"]: m for m in _req("GET", f"{url}/api/v3/movie", key, timeout=timeout)}
|
||||
out, files = [], []
|
||||
for it in items:
|
||||
movie = it.get("movie")
|
||||
if not movie:
|
||||
term = _title_from_path(it["relativePath"])
|
||||
look = _req("GET", f"{url}/api/v3/movie/lookup?term={urllib.parse.quote(term)}", key, timeout=timeout)
|
||||
if not look:
|
||||
out.append({"file": it["relativePath"], "status": "no_match", "term": term})
|
||||
continue
|
||||
cand = look[0]
|
||||
if cand["tmdbId"] in existing:
|
||||
movie = existing[cand["tmdbId"]]
|
||||
elif dry:
|
||||
out.append({"file": it["relativePath"], "status": "would_add+import",
|
||||
"match": f'{cand["title"]} ({cand.get("year")})'})
|
||||
continue
|
||||
else:
|
||||
cand.update({
|
||||
"qualityProfileId": qpid, "rootFolderPath": root,
|
||||
"monitored": True, "minimumAvailability": "released",
|
||||
"addOptions": {"searchForMovie": False},
|
||||
})
|
||||
movie = _req("POST", f"{url}/api/v3/movie", key, cand, timeout=timeout)
|
||||
existing[movie["tmdbId"]] = movie
|
||||
if dry:
|
||||
out.append({"file": it["relativePath"], "status": "would_import",
|
||||
"movie": f'{movie["title"]} ({movie.get("year")})'})
|
||||
continue
|
||||
files.append({
|
||||
"path": it["path"], "movieId": movie["id"], "quality": it["quality"],
|
||||
"languages": it.get("languages") or [{"id": 1, "name": "English"}],
|
||||
})
|
||||
out.append({"file": it["relativePath"], "status": "import_queued",
|
||||
"movie": f'{movie["title"]} ({movie.get("year")})', "movieId": movie["id"]})
|
||||
if files and not dry:
|
||||
cmd = _req("POST", f"{url}/api/v3/command", key,
|
||||
{"name": "ManualImport", "importMode": mode, "files": files}, timeout=timeout)
|
||||
for o in out:
|
||||
if o["status"] == "import_queued":
|
||||
o["commandId"] = cmd["id"]
|
||||
return out
|
||||
|
||||
|
||||
def _import_sonarr(url, key, drop, root, qpid, mode, dry, refresh_wait, timeout):
|
||||
enc = urllib.parse.quote(drop)
|
||||
items = _req("GET", f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true", key, timeout=timeout) or []
|
||||
existing = {s["tvdbId"]: s for s in _req("GET", f"{url}/api/v3/series", key, timeout=timeout)}
|
||||
out = []
|
||||
# 1. dar de alta series desconocidas
|
||||
unknown = {}
|
||||
for it in items:
|
||||
if not it.get("series"):
|
||||
unknown.setdefault(_title_from_path(it["relativePath"]), True)
|
||||
added_any = False
|
||||
for term in unknown:
|
||||
look = _req("GET", f"{url}/api/v3/series/lookup?term={urllib.parse.quote(term)}", key, timeout=timeout)
|
||||
if not look:
|
||||
out.append({"series_term": term, "status": "no_match"})
|
||||
continue
|
||||
cand = look[0]
|
||||
if cand["tvdbId"] in existing:
|
||||
continue
|
||||
if dry:
|
||||
out.append({"series_term": term, "status": "would_add",
|
||||
"match": f'{cand["title"]} ({cand.get("year")})'})
|
||||
continue
|
||||
for s in cand.get("seasons", []):
|
||||
s["monitored"] = True
|
||||
cand.update({
|
||||
"qualityProfileId": qpid, "rootFolderPath": root, "monitored": True,
|
||||
"addOptions": {"searchForMissingEpisodes": False, "searchForCutoffUnmetEpisodes": False},
|
||||
})
|
||||
srv = _req("POST", f"{url}/api/v3/series", key, cand, timeout=timeout)
|
||||
existing[srv["tvdbId"]] = srv
|
||||
added_any = True
|
||||
out.append({"series": f'{srv["title"]} ({srv.get("year")})', "status": "added", "seriesId": srv["id"]})
|
||||
if dry:
|
||||
for it in items:
|
||||
out.append({"file": it["relativePath"], "status": "would_import_after_add"})
|
||||
return out
|
||||
if added_any:
|
||||
time.sleep(refresh_wait) # esperar a que Sonarr refresque la ficha de episodios
|
||||
# 2. re-listar: ahora los episodios estan matcheados
|
||||
items = _req("GET", f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true", key, timeout=timeout) or []
|
||||
files = []
|
||||
for it in items:
|
||||
eps = it.get("episodes") or []
|
||||
ser = it.get("series")
|
||||
if not ser or not eps:
|
||||
out.append({"file": it["relativePath"], "status": "still_unmatched",
|
||||
"rej": [r.get("reason") for r in it.get("rejections", [])]})
|
||||
continue
|
||||
files.append({
|
||||
"path": it["path"], "seriesId": ser["id"],
|
||||
"episodeIds": [e["id"] for e in eps], "quality": it["quality"],
|
||||
"languages": it.get("languages") or [{"id": 1, "name": "English"}],
|
||||
})
|
||||
out.append({"file": it["relativePath"], "status": "import_queued",
|
||||
"episodes": [f'S{e.get("seasonNumber"):02d}E{e.get("episodeNumber"):02d}' for e in eps]})
|
||||
if files:
|
||||
cmd = _req("POST", f"{url}/api/v3/command", key,
|
||||
{"name": "ManualImport", "importMode": mode, "files": files}, timeout=timeout)
|
||||
for o in out:
|
||||
if o.get("status") == "import_queued":
|
||||
o["commandId"] = cmd["id"]
|
||||
return out
|
||||
|
||||
|
||||
def popelis_import_media_drop(
|
||||
radarr_url: str,
|
||||
radarr_key: str,
|
||||
sonarr_url: str,
|
||||
sonarr_key: str,
|
||||
movies_drop: str = "/data/manual/movies",
|
||||
tv_drop: str = "/data/manual/tv",
|
||||
movie_root: str = "/data/media/movies",
|
||||
tv_root: str = "/data/media/tv",
|
||||
quality_profile_id: int = 4,
|
||||
import_mode: str = "move",
|
||||
dry_run: bool = False,
|
||||
series_refresh_wait: float = 6.0,
|
||||
timeout: float = 60.0,
|
||||
) -> dict:
|
||||
"""Importa los drops de pelis y series via Radarr/Sonarr con metadata.
|
||||
|
||||
Pelis (Radarr): para cada fichero sin match, hace lookup por titulo,
|
||||
da de alta la pelicula (poster/fanart/ficha) si no existe y dispara un
|
||||
ManualImport (movieId explicito) que mueve el fichero a movie_root.
|
||||
|
||||
Series (Sonarr): detecta series desconocidas, las da de alta, espera a
|
||||
que Sonarr refresque la ficha de episodios, re-lista el drop (ahora los
|
||||
SxxExx matchean) e importa cada episodio con su episodeId. Ficheros con
|
||||
numeracion fuera de la ficha (ej. orden DVD vs aired) quedan reportados
|
||||
como 'still_unmatched' y permanecen en el drop para resolucion manual.
|
||||
|
||||
Args:
|
||||
radarr_url: URL base de Radarr sin trailing slash. Ej http://localhost:7878
|
||||
radarr_key: API key de Radarr (Settings > General).
|
||||
sonarr_url: URL base de Sonarr. Ej http://localhost:8989
|
||||
sonarr_key: API key de Sonarr.
|
||||
movies_drop: Carpeta drop de pelis EN EL NAMESPACE de Radarr (/data/...).
|
||||
tv_drop: Carpeta drop de series EN EL NAMESPACE de Sonarr (/data/...).
|
||||
movie_root: Root folder de Radarr donde se mueven las pelis.
|
||||
tv_root: Root folder de Sonarr donde se mueven las series.
|
||||
quality_profile_id: Quality profile para altas nuevas (4 = HD-1080p).
|
||||
import_mode: 'move' (default) o 'copy'. 'move' borra el origen.
|
||||
dry_run: Si True, no escribe nada: solo reporta que haria.
|
||||
series_refresh_wait: Segundos a esperar tras dar de alta una serie
|
||||
antes de re-listar (Sonarr necesita refrescar).
|
||||
timeout: Timeout HTTP por peticion.
|
||||
|
||||
Returns:
|
||||
Dict {"movies": [...], "tv": [...], "summary": {...}} con una entrada
|
||||
por fichero/serie y su status (import_queued | added | no_match |
|
||||
still_unmatched | would_*).
|
||||
|
||||
Raises:
|
||||
ValueError: Si una peticion HTTP devuelve 4xx/5xx.
|
||||
urllib.error.URLError: Si no se puede conectar a una *arr.
|
||||
"""
|
||||
movies = _import_radarr(radarr_url.rstrip("/"), radarr_key, movies_drop,
|
||||
movie_root, quality_profile_id, import_mode, dry_run, timeout)
|
||||
tv = _import_sonarr(sonarr_url.rstrip("/"), sonarr_key, tv_drop, tv_root,
|
||||
quality_profile_id, import_mode, dry_run, series_refresh_wait, timeout)
|
||||
summary = {
|
||||
"movies_queued": sum(1 for o in movies if o["status"] in ("import_queued", "would_add+import", "would_import")),
|
||||
"movies_unmatched": sum(1 for o in movies if o["status"] == "no_match"),
|
||||
"tv_queued": sum(1 for o in tv if o.get("status") == "import_queued"),
|
||||
"tv_added_series": sum(1 for o in tv if o.get("status") in ("added", "would_add")),
|
||||
"tv_unmatched": sum(1 for o in tv if o.get("status") in ("still_unmatched", "no_match")),
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
return {"movies": movies, "tv": tv, "summary": summary}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import os
|
||||
|
||||
p = argparse.ArgumentParser(description="Importa drops de Popelis via Radarr/Sonarr")
|
||||
p.add_argument("--radarr-url", default=os.environ.get("RADARR_URL", "http://localhost:7878"))
|
||||
p.add_argument("--radarr-key", default=os.environ.get("RADARR_KEY", ""))
|
||||
p.add_argument("--sonarr-url", default=os.environ.get("SONARR_URL", "http://localhost:8989"))
|
||||
p.add_argument("--sonarr-key", default=os.environ.get("SONARR_KEY", ""))
|
||||
p.add_argument("--apply", action="store_true", help="Ejecuta de verdad (default dry-run)")
|
||||
args = p.parse_args()
|
||||
res = popelis_import_media_drop(
|
||||
args.radarr_url, args.radarr_key, args.sonarr_url, args.sonarr_key,
|
||||
dry_run=not args.apply,
|
||||
)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: popelis_set_password
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def popelis_set_password(base_url: str, admin_token: str, username: str, password: str, timeout: float = 30.0) -> dict"
|
||||
description: "Cambia la contrasena de un usuario existente en Popelis (POST /api/admin/users/password). Devuelve 404 si el usuario no existe. Registro CERRADO: requiere cabecera X-Admin-Token."
|
||||
tags: [popelis, http, admin, user, password, infra]
|
||||
params:
|
||||
- name: base_url
|
||||
desc: "URL base del servicio sin trailing slash. Ej: https://popelis.datardos.com"
|
||||
- name: admin_token
|
||||
desc: "Token de administracion. Se envia como cabecera X-Admin-Token. No logear ni exponer."
|
||||
- name: username
|
||||
desc: "Nombre del usuario al que se le quiere cambiar la contrasena. Debe existir previamente."
|
||||
- name: password
|
||||
desc: "Nueva contrasena a establecer."
|
||||
- name: timeout
|
||||
desc: "Timeout en segundos para la peticion HTTP. Default 30.0."
|
||||
output: "Dict con el resultado de la operacion: {status: 'password updated'}"
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json", "urllib.request", "urllib.error"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/popelis_set_password.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra.popelis_set_password import popelis_set_password
|
||||
|
||||
result = popelis_set_password(
|
||||
base_url="https://popelis.datardos.com",
|
||||
admin_token="<admin-token>",
|
||||
username="alice",
|
||||
password="n3wpass2024",
|
||||
)
|
||||
# result == {"status": "password updated"}
|
||||
print(result)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites resetear o cambiar la contrasena de un usuario ya existente en Popelis desde un script de administracion o agente. Usar DESPUES de `popelis_create_user` (el usuario debe existir). Tambien util para rotacion periodica de credenciales.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Token sensible**: `admin_token` es un secreto. Nunca logear el valor, interpolarlo en URLs ni persistirlo en texto plano.
|
||||
- **Registro cerrado**: el endpoint rechaza cualquier peticion sin `X-Admin-Token` valido (HTTP 401/403).
|
||||
- **404 si no existe**: si el `username` no esta registrado, el servidor devuelve HTTP 404 y la funcion lanza `ValueError` con mensaje explicito (`usuario 'X' no existe`). Verificar existencia previa o capturar el error.
|
||||
- **No afecta a Jellyfin**: este endpoint cambia solo la contrasena en Popelis. Si el usuario Jellyfin espejo tiene contrasena separada, habra que gestionarla por separado via la API de Jellyfin.
|
||||
- **Solo stdlib**: no requiere `requests` ni dependencias externas — usa `urllib.request`.
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Cambia la contrasena de un usuario en la API de administracion de Popelis."""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def popelis_set_password(
|
||||
base_url: str,
|
||||
admin_token: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""Cambia la contrasena de un usuario existente en Popelis.
|
||||
|
||||
Hace POST a {base_url}/api/admin/users/password con el token de admin,
|
||||
el nombre de usuario y la nueva contrasena. Devuelve 404 si el usuario
|
||||
no existe.
|
||||
|
||||
Args:
|
||||
base_url: URL base del servicio, sin trailing slash.
|
||||
Ej: "https://popelis.datardos.com"
|
||||
admin_token: Token de administracion (cabecera X-Admin-Token).
|
||||
Mantenerlo en secreto — no logear.
|
||||
username: Nombre de usuario cuya contrasena se quiere cambiar.
|
||||
password: Nueva contrasena a establecer.
|
||||
timeout: Timeout en segundos para la peticion HTTP. Default 30.0.
|
||||
|
||||
Returns:
|
||||
Dict con el resultado: {"status": "password updated"}
|
||||
|
||||
Raises:
|
||||
ValueError: Si el servidor devuelve 4xx o 5xx (incluyendo 404 cuando
|
||||
el usuario no existe). El mensaje incluye el status code
|
||||
y el campo "error" del body JSON si existe.
|
||||
urllib.error.URLError: Si no se puede conectar al servidor.
|
||||
"""
|
||||
url = f"{base_url.rstrip('/')}/api/admin/users/password"
|
||||
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Admin-Token": admin_token,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read()
|
||||
return json.loads(body)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
try:
|
||||
detail = json.loads(raw).get("error", raw.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
detail = raw.decode("utf-8", errors="replace")
|
||||
if exc.code == 404:
|
||||
raise ValueError(
|
||||
f"popelis_set_password: usuario '{username}' no existe (HTTP 404) — {detail}"
|
||||
) from exc
|
||||
raise ValueError(
|
||||
f"popelis_set_password: HTTP {exc.code} — {detail}"
|
||||
) from exc
|
||||
Reference in New Issue
Block a user