Files
labstream-organizador/organizador.py
Labstream 2daffc175a 2.6.2: arranque en blanco
- Una instalación nueva no trae carpetas de fábrica: CONFIG_DEFECTO
  arrancaba con David Reyes, Danney y Camilo apuntando a Z:, W: y N:.
  raices viene vacía; el diccionario de clientes y discos sigue de serie
  porque es conocimiento, no estado.
- Al abrir no se entra sola en ninguna carpeta: la primera opción del
  desplegable está vacía y la portada enseña «Tus carpetas» con su último
  escaneo, para entrar con un clic.
- Un config.json ilegible se aparta en config.json.roto-<fecha> en vez de
  sustituirse por los valores de fábrica en silencio.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 08:10:36 -05:00

4463 líneas
223 KiB
Python
Original Blame Histórico

Este archivo contiene caracteres Unicode invisibles

Este archivo contiene caracteres Unicode invisibles que son indistinguibles para los humanos, pero que pueden ser procesados de forma diferente por un ordenador. Si crees que esto es intencional, puedes ignorar esta advertencia. Usa el botón de Escape para revelarlos.

Este archivo contiene caracteres Unicode que pueden confundirse con otros caracteres. Si crees que esto es intencional, puedes ignorar esta advertencia. Usa el botón de Escape para revelarlos.

# -*- coding: utf-8 -*-
"""
Organizador Labstream — inventario, mapa, orden y optimización del material.
Solo librería estándar de Python (3.10+). Arranca un servidor local y abre la
interfaz en el navegador:
py -3.12 organizador.py (o doble clic en Iniciar.bat)
Nunca borra nada: lo que sobra va a _Para borrar dentro de la misma raíz y cada
movimiento queda en datos/bitacora.jsonl para poder deshacerlo.
"""
import os
import sys
import re
import json
import zlib
import struct
import ctypes
import time
import queue
import shutil
import stat as stat_mod
import hashlib
import sqlite3
import threading
import subprocess
import datetime
import webbrowser
from collections import Counter, defaultdict
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import reglas as R
import asistente
import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "2.6.2"
CONGELADO = getattr(sys, "frozen", False)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
BASE = os.path.dirname(os.path.abspath(sys.executable if CONGELADO else __file__))
DATOS = os.path.join(BASE, "datos")
os.makedirs(DATOS, exist_ok=True)
DB_PATH = os.path.join(DATOS, "inventario.sqlite")
CONFIG_PATH = os.path.join(DATOS, "config.json")
DICC_PATH = os.path.join(DATOS, "diccionario.json")
BITACORA_PATH = os.path.join(DATOS, "bitacora.jsonl")
PLANTILLAS_PATH = os.path.join(DATOS, "plantillas.json")
PUERTO = int(os.environ.get("ORGANIZADOR_PUERTO", "8765"))
LOCK = threading.RLock()
# ==========================================================================
# Configuración y diccionario (lo que la app aprende)
# ==========================================================================
CONFIG_DEFECTO = {
# ninguna carpeta puesta de fábrica: la primera vez el programa abre en blanco y las carpetas
# entran cuando alguien las suelta. Lo que sí viene de serie es el diccionario de clientes y
# discos del estudio, que es conocimiento, no estado.
"raices": [],
"clientes": {
"David Reyes": {"tipo": "contenido", "alias": ["DR. DAVID REYES", "David Reyes", "Davids", "Dr David Reyes"]},
"Danney Gómez": {"tipo": "contenido", "alias": ["Danney", "DRA. DANNEY GOMEZ", "Dra_Danney", "Dra Danney", "Skincenter"]},
"Camilo Ortega": {"tipo": "pauta", "alias": ["Camilo Ortega", "Camilo", "Camilo Ortega Anuncios", "Camilo Anuncio", "VSL Camilo Ortega", "Apoyos Camilo", "Anuncios Jonathan"]},
"Andrea Galeano": {"tipo": "contenido", "alias": ["DRA. ANDREA GALEANO", "Galeano", "Portadas Galeano"]},
"Carolina Solórzano": {"tipo": "contenido", "alias": ["DRA. CAROLINA SOLORZANO", "Solorzano"]},
"Diana Méndez": {"tipo": "contenido", "alias": ["DRA. DIANA MENDEZ", "Diana Mendez", "DRA. DIANA MENDEZ (1)"]},
"Luisa Báez": {"tipo": "contenido", "alias": ["DRA. LUISA BAEZ", "Luisa Baez"]},
"Malpraxis": {"tipo": "pauta", "alias": ["Malpraxis", "Malpraxiss", "Malpraxis 2026", "App Malpraxis - Hotjamin"]},
"Brigard & Urrutia": {"tipo": "proyecto", "alias": ["Brigard & Urrutia", "Brigard Urrutia", "Brigard", "Brigard 2025", "Brigard 2026"]},
"PepsiCo": {"tipo": "proyecto", "alias": ["PepsiCo", "pepsico"]},
"Kingspan / Synthesia": {"tipo": "proyecto", "alias": ["Kingspam", "Kingspan", "Sinthesia", "Syntesia", "SYNTHESIA - KINGSPAN"]},
"Filarmónica de Bogotá": {"tipo": "proyecto", "alias": ["FILARMONICA DE BOGOTA", "Filarmónica de Bogotá", "Filarmonica"]},
"Fundación Vascular": {"tipo": "proyecto", "alias": ["CLINICA FUNDACIÓN VASCULAR", "Fundación Vascular"]},
"Legal Core": {"tipo": "proyecto", "alias": ["LEGAL CORE", "Legal Core"]},
"Vía Sumapaz": {"tipo": "proyecto", "alias": ["Vía SUMAPAZ", "VÍA SUMAPAZ", "Sumapaz", "Páramo Sumapaz lucy"]},
"Latinología": {"tipo": "proyecto", "alias": ["Latinología"]},
"CICR": {"tipo": "proyecto", "alias": ["CICR"]},
"Andrés Arango": {"tipo": "proyecto", "alias": ["Andres Arango"]},
"Labstream (interno)": {"tipo": "interno", "alias": ["LABSTREAM", "LABSTREAM ACADEMY", "Labstream Medicina", "producción", "Recursos audiovisuales", "Recursos Audiovisuales", "Sonidos"]},
},
"discos": {
"Z:": {"nombre": "labstream · NAS Labstream", "nivel": "trabajo"},
"W:": {"nombre": "Dra_Danney · Cloud Store Mini (10G)", "nivel": "caliente"},
"N:": {"nombre": "Camilo Ortega · NAS Labstream", "nivel": "trabajo"},
"K:": {"nombre": "Entregas_LAB · LabTem (lo maneja Labstream OS)", "nivel": "entregas"},
"Q:": {"nombre": "Exportados · NAS Labstream (lo maneja Labstream OS)", "nivel": "entregas"},
"T:": {"nombre": "temple · LabTem", "nivel": "frio"},
"M:": {"nombre": "Malpraxis · NAS Labstream", "nivel": "trabajo"},
"X:": {"nombre": "Datos · NAS Labstream", "nivel": "frio"},
"Y:": {"nombre": "Nubes · NAS Labstream", "nivel": "frio"},
"V:": {"nombre": "Medicina1 · NAS Labstream", "nivel": "trabajo"},
"U:": {"nombre": "Reels · LabTem", "nivel": "trabajo"},
"L:": {"nombre": "Apoyos Camilo · LabTem", "nivel": "trabajo"},
"R:": {"nombre": "Recursos Audiovisuales · LabTem", "nivel": "recursos"},
"S:": {"nombre": "Sonidos · LabTem", "nivel": "recursos"},
"O:": {"nombre": "Operaciones_LAB · NAS Labstream", "nivel": "sistema"},
},
"optimizar": {
"meses_minimos": 3,
"min_mb": 300,
"perfil": "equilibrado",
"formato": "h265_10bit",
"chroma": "420",
"color": "rec709",
"salida": "junto", # junto = al lado del original con sufijo _10bit ; espejo = _Archivo/<ruta>
"sufijo": "_10bit",
"ffmpeg": "ffmpeg",
"ffprobe": "ffprobe",
"python_resolve": "", # vacío = el mismo intérprete
},
}
PERFILES = {
"ahorro": {"bpp": 0.055, "cq": 27, "nombre": "Ahorro (pesa menos)"},
"equilibrado": {"bpp": 0.085, "cq": 24, "nombre": "Equilibrado (recomendado)"},
"maxima": {"bpp": 0.14, "cq": 21, "nombre": "Máxima calidad"},
}
# Formatos de salida del convertidor. gpu/cpu = codificador de ffmpeg que lo hace; se usa la GPU si está
# disponible y si no la CPU. bpp_mult escala el bitrate objetivo del perfil (H.265 10 bits = 1.0).
# ProRes no va por bitrate: mbps_1080p30 es su peso aproximado a 1080p 30 fps para estimar.
FORMATOS = {
"h265_10bit": {"nombre": "MP4 H.265 10 bits · archivo (recomendado)", "ext": ".mp4", "codec": "hevc", "bits": 10, "sufijo": "_10bit",
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 1.0, "que": "unas 10 veces más ligero que el original, sin perder calidad visible; el que usa el respaldo"},
"h265_ligero": {"nombre": "MP4 H.265 8 bits · ligero para compartir", "ext": ".mp4", "codec": "hevc", "bits": 8, "sufijo": "_ligero",
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "cq_mas": 5,
"que": "para enviar por WhatsApp o Drive; pierde latitud de color"},
"h264": {"nombre": "MP4 H.264 8 bits · compatible con todo", "ext": ".mp4", "codec": "h264", "bits": 8, "sufijo": "_h264",
"gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "cq_mas": -2,
"que": "se abre en cualquier equipo o televisor; pesa más que H.265"},
"prores_proxy": {"nombre": "MOV ProRes Proxy · edición ligera", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_proxy",
"cpu": "prores_ks", "prores": 0, "mbps_1080p30": 45, "que": "para editar suave en Resolve o Premiere; luego se reenlaza al original"},
"prores_lt": {"nombre": "MOV ProRes 422 LT · edición", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_LT",
"cpu": "prores_ks", "prores": 1, "mbps_1080p30": 102, "que": "calidad de edición; pesa bastante"},
"prores_hq": {"nombre": "MOV ProRes 422 HQ · máster", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_HQ",
"cpu": "prores_ks", "prores": 3, "mbps_1080p30": 220, "que": "entrega o máster; pesa mucho"},
}
_MOTORES = {"ts": 0, "datos": None}
def motores():
"""Qué codificadores hay: GPU NVIDIA (NVENC) y CPU. Se prueba de verdad (un clip de 3 cuadros), no solo
por la lista de ffmpeg, porque la lista incluye nvenc aunque no haya tarjeta NVIDIA."""
if _MOTORES["datos"] and time.time() - _MOTORES["ts"] < 600:
return _MOTORES["datos"]
ff = ruta_herramienta("ffmpeg")
out = {"gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False},
"cpu": {"libx265": False, "libx264": False, "prores_ks": False},
"formatos": {}, "chroma422": False, "ffmpeg": bool(ff)}
disponibles = set()
if ff:
try:
r = subprocess.run([ff, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA)
for linea in r.stdout.splitlines():
partes = linea.split()
if len(partes) >= 2 and partes[0][:1] in ("V", "A") and len(partes[0]) == 6:
disponibles.add(partes[1])
except Exception: # noqa
pass
for k in ("libx265", "libx264", "prores_ks"):
out["cpu"][k] = k in disponibles
for k, enc in (("hevc", "hevc_nvenc"), ("h264", "h264_nvenc")):
if enc in disponibles:
try:
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=256x144:rate=30",
"-frames:v", "3", "-c:v", enc, "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
out["gpu"][k] = r.returncode == 0
except Exception: # noqa
pass
if out["gpu"]["hevc"]:
try:
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
"-i", "testsrc=size=256x144:rate=30", "-frames:v", "3",
"-c:v", "hevc_nvenc", "-pix_fmt", "p210le", "-profile:v", "rext",
"-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
out["gpu"]["chroma422"] = r.returncode == 0
except Exception: # noqa
out["gpu"]["chroma422"] = False
out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265"))
try:
r = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, creationflags=SIN_VENTANA)
out["gpu"]["nombre"] = (r.stdout or "").strip().splitlines()[0] if r.returncode == 0 and r.stdout.strip() else ""
except Exception: # noqa
pass
for k, f in FORMATOS.items():
m = None
if f.get("gpu") and out["gpu"].get(f["codec"]):
m = "gpu"
elif f.get("cpu") and out["cpu"].get(f["cpu"]):
m = "cpu"
out["formatos"][k] = m # None = no se puede con este ffmpeg
_MOTORES.update(ts=time.time(), datos=out)
return out
def cargar_json(ruta, defecto):
try:
with open(ruta, "r", encoding="utf-8") as f:
return json.load(f)
except OSError:
return json.loads(json.dumps(defecto))
except ValueError as ex:
# el archivo existe pero no se puede leer (una edición a mano con una coma de más, un corte a
# medias). Antes se sustituía por los valores de fábrica y se guardaba encima: las carpetas
# registradas y los clientes desaparecían sin decir nada. Ahora se aparta con copia.
copia = f"{ruta}.roto-{time.strftime('%Y%m%d-%H%M%S')}"
try:
shutil.copy2(ruta, copia)
except OSError:
copia = "(no se pudo copiar)"
print(f"AVISO: {os.path.basename(ruta)} no se pudo leer ({ex}); se apartó en {copia} y se parte de cero.")
return json.loads(json.dumps(defecto))
def guardar_json(ruta, datos):
tmp = ruta + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(datos, f, ensure_ascii=False, indent=2)
os.replace(tmp, ruta)
CONFIG = cargar_json(CONFIG_PATH, CONFIG_DEFECTO)
for k, v in CONFIG_DEFECTO.items(): # completar claves nuevas sin pisar lo del usuario
CONFIG.setdefault(k, v)
if isinstance(v, dict):
for kk, vv in v.items():
CONFIG[k].setdefault(kk, vv)
guardar_json(CONFIG_PATH, CONFIG)
def ruta_herramienta(h):
"""ffmpeg/ffprobe: primero el empaquetado en ffmpeg\\ junto al programa, luego lo que diga la config, luego el PATH.
No se guarda en la config para que la carpeta se pueda mover de sitio."""
local = os.path.join(BASE, "ffmpeg", h + ".exe")
if os.path.exists(local):
return local
return CONFIG["optimizar"].get(h) or h
def herramientas():
"""qué hay disponible: ffmpeg/ffprobe y Resolve"""
out = {}
for h in ("ffmpeg", "ffprobe"):
ruta = ruta_herramienta(h)
ok = os.path.exists(ruta) or shutil.which(ruta) is not None
out[h] = {"ruta": ruta, "ok": ok, "empaquetado": ruta.lower().startswith(BASE.lower())}
out["resolve"] = {"ok": os.path.exists(r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll"), "ruta": r"C:\Program Files\Blackmagic Design\DaVinci Resolve"}
out["congelado"] = CONGELADO
return out
DICC = cargar_json(DICC_PATH, {"carpetas": R.DICCIONARIO_SEMILLA, "reglas": [], "aprendido": []})
DICC.setdefault("carpetas", dict(R.DICCIONARIO_SEMILLA))
DICC.setdefault("reglas", [])
DICC.setdefault("aprendido", [])
for k, v in R.DICCIONARIO_SEMILLA.items(): # palabras nuevas de la semilla, sin pisar lo aprendido
DICC["carpetas"].setdefault(k, v)
guardar_json(DICC_PATH, DICC)
_act = CONFIG.get("actualizaciones")
if isinstance(_act, dict) and _act.get("auto") is True and not _act.get("elegido"):
_act["auto"] = False
guardar_json(CONFIG_PATH, CONFIG)
PLANTILLAS = cargar_json(PLANTILLAS_PATH, R.PLANTILLAS)
for t, d in R.PLANTILLAS.items():
PLANTILLAS.setdefault(t, d)
for b, p in d.items():
PLANTILLAS[t].setdefault(b, p)
# la papelera se llamaba «_Papelera» y pasó a «_Para borrar», pero las plantillas guardadas ganan
# sobre las de fábrica, así que las instalaciones viejas seguían escribiendo en la carpeta vieja
# mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
PLANTILLAS[t][b] = p
# los proyectos pasaron a separarse por programa: «01_Proyectos/{nombre}» -> «01_Proyectos/{app}/{nombre}»
if b == "PROYECTOS" and PLANTILLAS[t][b] == p.replace("{app}/", ""):
PLANTILLAS[t][b] = p
# y las grabaciones, por día de rodaje cuando el mes tiene más de uno
if b in ("GRABACION", "CELULAR") and PLANTILLAS[t][b] == p.replace("{dia}/", ""):
PLANTILLAS[t][b] = p
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
def raiz_cfg(ruta):
ruta = os.path.normpath(ruta)
for r in CONFIG["raices"]:
if os.path.normpath(r["ruta"]).lower() == ruta.lower():
return r
return None
def dentro_de(raiz, ruta):
"""True si `ruta` está dentro de `raiz` (o es la propia raíz). Evita mover cosas fuera de la carpeta del cliente."""
try:
a = os.path.normcase(os.path.normpath(raiz))
b = os.path.normcase(os.path.normpath(ruta))
except (TypeError, ValueError):
return False
return b == a or b.startswith(a.rstrip(os.sep) + os.sep)
# ==========================================================================
# Base de datos
# ==========================================================================
def db():
con = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=60)
con.row_factory = sqlite3.Row
return con
def init_db():
con = db()
con.executescript("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS archivos (
ruta TEXT PRIMARY KEY, raiz TEXT, dir TEXT, nombre TEXT, ext TEXT, bytes INTEGER, mtime REAL,
rol TEXT, fuente TEXT, contexto TEXT, anio_ruta INTEGER, mes_ruta INTEGER,
fecha TEXT, fecha_origen TEXT, es_basura INTEGER, es_proxy INTEGER,
codec TEXT, perfil TEXT, pix_fmt TEXT, ancho INTEGER, alto INTEGER, fps REAL, dur REAL,
kbps INTEGER, alfa INTEGER, acodec TEXT, analizado INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ix_arch_dir ON archivos(dir);
CREATE INDEX IF NOT EXISTS ix_arch_raiz ON archivos(raiz);
CREATE INDEX IF NOT EXISTS ix_arch_nb ON archivos(nombre, bytes);
CREATE TABLE IF NOT EXISTS carpetas (
ruta TEXT PRIMARY KEY, raiz TEXT, nombre TEXT, n INTEGER, bytes INTEGER,
anio_moda INTEGER, mes_moda INTEGER, frac_moda REAL, dias_rango INTEGER,
es_proxy INTEGER, es_basura INTEGER
);
CREATE INDEX IF NOT EXISTS ix_carp_raiz ON carpetas(raiz);
CREATE TABLE IF NOT EXISTS escaneos (raiz TEXT PRIMARY KEY, ts REAL, n INTEGER, bytes INTEGER, seg REAL);
CREATE TABLE IF NOT EXISTS trabajos (
id INTEGER PRIMARY KEY AUTOINCREMENT, ruta TEXT, salida TEXT, motor TEXT, perfil TEXT,
chroma TEXT, color TEXT, kbps INTEGER, estado TEXT, pct REAL DEFAULT 0, log TEXT DEFAULT '',
bytes_in INTEGER, bytes_out INTEGER, creado REAL, fin REAL, raiz TEXT
);
""")
con.commit()
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",
"ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'",
"ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1"):
try:
con.execute(alter)
con.commit()
except sqlite3.OperationalError:
pass
con.close()
init_db()
def con_sep(ruta):
r"""la ruta con un separador al final, sin duplicarlo (N: ya lo trae)"""
ruta = os.path.normpath(ruta)
return ruta if ruta.endswith(os.sep) else ruta + os.sep
def esc_like(s):
return s.replace("!", "!!").replace("%", "!%").replace("_", "!_")
def archivos_bajo(con, carpeta):
"""filas de archivos dentro de una carpeta (recursivo)"""
c = os.path.normpath(carpeta)
return con.execute(
"SELECT * FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'",
(c, esc_like(con_sep(c)) + "%")).fetchall()
def archivos_en(con, carpeta):
return con.execute("SELECT * FROM archivos WHERE dir = ?", (os.path.normpath(carpeta),)).fetchall()
# ==========================================================================
# Escaneo (inventario)
# ==========================================================================
PROGRESO = {} # raiz -> dict
def contexto_dir(nombre, ctx_padre, nivel_padre):
"""cubeta de contexto que hereda un archivo por la carpeta donde está"""
if R.detectar_anio(nombre):
return "ANIO", "anio"
if nivel_padre == "anio" and R.detectar_mes(nombre)[0]:
return "MES", "mes"
if nivel_padre == "mes" and R.detectar_pieza(nombre):
return "PIEZA", "pieza"
cub, _ = R.cubeta_carpeta(nombre, DICC["carpetas"])
if cub in (None, "CONTENEDOR"):
return ctx_padre, nivel_padre if cub == "CONTENEDOR" else nivel_padre
if cub == "PIEZA":
return "PIEZA", "pieza"
return cub, nivel_padre
def _es_enlace(entrada):
"""True si la entrada es un enlace o una unión de Windows (mklink /D o /J). `is_symlink` no basta:
para una unión devuelve False, y aun así al entrar se recorre otro árbol."""
try:
if entrada.is_symlink():
return True
return bool(entrada.stat(follow_symlinks=False).st_file_attributes & stat_mod.FILE_ATTRIBUTE_REPARSE_POINT)
except (OSError, AttributeError):
return False
def _id_carpeta(ruta):
"""Identificador real de una carpeta en el disco, para no recorrer dos veces la misma."""
st = os.stat(ruta)
return (st.st_dev, st.st_ino) if st.st_ino else os.path.normcase(os.path.abspath(ruta))
def escanear(ruta):
raiz = os.path.normpath(ruta)
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
filas = []
fallos = [] # carpetas o archivos que no se pudieron leer: deciden si el escaneo vale
dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
props = ("n", "bytes")
def agrega_dir(d, size, fecha_am):
info = dirs.get(d)
if info is None:
info = dirs[d] = [0, 0, Counter(), 0, 0, os.path.basename(d)]
info[0] += 1
info[1] += size
info[2][fecha_am] += 1
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None):
# `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows
# (mklink /J) parece una carpeta normal, y una que apunte hacia arriba dejaba el escaneo dando
# vueltas sin fin (comprobado: un árbol con un archivo no terminaba en dos minutos).
if vistos is None:
vistos = set()
try:
clave = _id_carpeta(path)
if clave in vistos:
fallos.append(f"{path}: es un acceso directo a una carpeta ya recorrida; no se entra")
return
vistos = vistos | {clave}
except OSError:
pass
try:
it = os.scandir(path)
except OSError as ex:
fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})")
return
prog["actual"] = path
with it:
for e in it:
try:
nombre = e.name
if e.is_dir(follow_symlinks=False):
if nombre.lower() in ("#recycle", "@eadir", R.CARPETA_APP):
continue # lo de la app (sus iconos de color) no es material del cliente
p = en_proxy or R.es_proxy_dir(nombre)
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
# medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de
# material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye
# por defecto. Solo hereda la marca una carpeta con nombre completo de paquete de
# Envato; lo que de verdad se descargó se reconoce por el nombre del propio archivo.
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip()))
c2, n2 = contexto_dir(nombre, ctx, nivel)
a2, m2 = anio, mes
if c2 == "ANIO":
a2, m2 = R.detectar_anio(nombre), None
elif c2 == "MES":
m2 = R.detectar_mes(nombre)[0]
dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre])
dirs[e.path][3] = 1 if p else 0
dirs[e.path][4] = 1 if b else 0
if _es_enlace(e):
fallos.append(f"{e.path}: es un acceso directo a otra carpeta; no se entra")
continue
walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
else:
if nombre.lower() == "desktop.ini" and _ini_nuestro(e.path):
continue # el color de la carpeta lo puso la app: ni se indexa ni se aparta
st = e.stat(follow_symlinks=False)
ext = os.path.splitext(nombre)[1].lower()
firma = R.firma_archivo(nombre)
rol = R.rol_archivo(nombre, ext, firma)
basura = 1 if (en_basura or rol == "basura") else 0
proxy = 1 if (en_proxy or rol == "proxy") else 0
stock = 1 if (en_stock or rol == "stock") else 0
fecha, origen = R.fecha_de_nombre(nombre, R.fecha_real(st))
fy, fm = int(fecha[:4]), int(fecha[5:7])
filas.append((e.path, raiz, path, nombre, ext, st.st_size, st.st_mtime, rol, firma or "",
ctx or "", anio, mes, fecha, origen, basura, proxy, stock))
prog["n"] += 1
prog["bytes"] += st.st_size
d = path
while True:
agrega_dir(d, st.st_size, (fy, fm))
if os.path.normpath(d).lower() == raiz.lower():
break
padre = os.path.dirname(d)
if padre == d:
break
d = padre
except OSError as ex:
fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})")
continue
try:
walk(raiz, False, False, False, "", "raiz", None, None)
prog["fallos"] = len(fallos)
_heredar_fechas(filas)
con = db()
# Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de
# red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de
# ahí el plan, el respaldo y el convertidor trabajaban sobre datos parciales sin que nadie lo supiera.
anterior = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
if fallos and anterior and anterior["n"] and prog["n"] < anterior["n"] * 0.9:
con.close()
prog["estado"] = "error"
prog["error"] = (f"el escaneo no pudo leer {len(fallos)} sitios y solo encontró {prog['n']:,} archivos "
f"frente a {anterior['n']:,} del anterior: se conserva el inventario que ya había. "
f"Comprueba que la unidad esté conectada. Primero que falló: {fallos[0]}")
prog["fin"] = time.time()
return
with con:
# conservar el análisis de ffprobe de los archivos que no cambiaron
previo = {r["ruta"]: r for r in con.execute(
"SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, "
"pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()}
con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,))
con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,))
con.executemany(
"INSERT OR REPLACE INTO archivos (ruta, raiz, dir, nombre, ext, bytes, mtime, rol, fuente, contexto, "
"anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
filas)
for f in filas:
pv = previo.get(f[0])
if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2:
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
(pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0]))
cfilas = []
for d, info in dirs.items():
n, b, fechas, p, bs, nombre = info
if fechas:
(ay, am), cnt = fechas.most_common(1)[0]
frac = cnt / n
claves = sorted(fechas)
d0 = datetime.date(claves[0][0], claves[0][1], 1)
d1 = datetime.date(claves[-1][0], claves[-1][1], 1)
rango = (d1 - d0).days
else:
ay, am, frac, rango = None, None, 0, 0
cfilas.append((d, raiz, nombre, n, b, ay, am, frac, rango, p, bs))
con.executemany("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", cfilas)
con.execute("INSERT OR REPLACE INTO escaneos VALUES (?,?,?,?,?)",
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"]))
con.close()
prog["estado"] = "listo"
if fallos:
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
except Exception as ex: # noqa
prog["estado"] = "error"
prog["error"] = repr(ex)
prog["fin"] = time.time()
def _heredar_fechas(filas):
"""Un render o una conversión se crean mucho después del rodaje, así que su fecha no dice nada del
material: si el original está en la misma carpeta, el derivado toma SU fecha.
Así «C0001_10bit.mp4» se archiva en el mes en que se grabó «C0001.MP4», no en el mes en que se
convirtió, y el mes de una carpeta se decide por el material de verdad."""
I_RUTA, I_DIR, I_NOMBRE, I_FECHA, I_ORIGEN = 0, 2, 3, 12, 13
por_dir = defaultdict(dict)
en_todo = defaultdict(list)
for f in filas:
tallo = os.path.splitext(f[I_NOMBRE])[0].lower()
por_dir[f[I_DIR].lower()][tallo] = f
en_todo[tallo].append(f)
heredadas = 0
for i, f in enumerate(filas):
base = R.base_derivado(f[I_NOMBRE])
if not base:
continue
orig = por_dir[f[I_DIR].lower()].get(base.lower())
if orig is None and len(base) >= 6:
# el render puede estar en otra carpeta («_Exportados», «_Archivo»): vale si el nombre es
# bastante propio y no hay dos candidatos que se llamen igual en toda la carpeta del cliente
cands = en_todo.get(base.lower()) or []
orig = cands[0] if len(cands) == 1 else None
if orig is None or orig is f or orig[I_ORIGEN] == "derivado":
continue
if orig[I_FECHA] >= f[I_FECHA]:
continue # el «original» no es más viejo: no hereda nada
nueva = list(f)
nueva[I_FECHA] = orig[I_FECHA]
nueva[I_ORIGEN] = "derivado" # la fecha viene del archivo del que salió
filas[i] = tuple(nueva)
heredadas += 1
return heredadas
def lanzar_escaneo(ruta):
raiz = os.path.normpath(ruta)
p = PROGRESO.get(raiz)
if p and p["estado"] == "corriendo":
return False
threading.Thread(target=escanear, args=(raiz,), daemon=True).start()
return True
# ==========================================================================
# Utilidades de fechas / nombres para el plan
# ==========================================================================
def fecha_carpeta(con, ruta):
"""(anio, mes, confianza, motivo) para una carpeta según sus archivos"""
fila = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (os.path.normpath(ruta),)).fetchone()
if not fila or not fila["anio_moda"]:
return None, None, "baja", "sin archivos con fecha"
frac = fila["frac_moda"] or 0
if frac >= 0.8:
return fila["anio_moda"], fila["mes_moda"], "media", f"fecha de sus archivos ({int(frac * 100)}% del mismo mes)"
return fila["anio_moda"], fila["mes_moda"], "baja", f"fechas mezcladas ({fila['dias_rango']} días de rango)"
def limpiar_nombre(nombre):
n = re.sub(r"\s{2,}", " ", nombre).strip(" -_.")
return n or nombre
def destino_plantilla(tipo, cubeta, **kw):
plantilla = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get(cubeta)
if not plantilla:
plantilla = PLANTILLAS["contenido"].get(cubeta, "_Revisar/{nombre}")
anio = kw.get("anio")
mes = kw.get("mes")
partes = {
"anio": str(anio) if anio else "",
"mes": R.nombre_mes(mes) if mes else "",
"nombre": kw.get("nombre", ""),
"dia": kw.get("dia", ""),
"sub": kw.get("sub", ""),
"rel": kw.get("rel", ""),
"fuente": kw.get("fuente", ""),
"app": kw.get("app", ""),
}
# un valor vacío se lleva consigo el separador que lo precedía: "{anio}-{mes}_Grabacion" sin mes -> "2026_Grabacion"
vacio = "\x00"
partes_o = {k: (v if v != "" else vacio) for k, v in partes.items()}
segmentos = []
for seg in plantilla.split("/"):
s = seg.format(**partes_o)
s = re.sub(r"[-_ ]*\x00", "", s)
if seg.startswith("{"):
s = s.lstrip("-_ ")
if seg.endswith("}"):
s = s.rstrip("-_ ")
if s:
segmentos.append(s)
if not segmentos:
segmentos = ["_Sin fecha"]
return os.sep.join(segmentos)
# ==========================================================================
# Planificador
# ==========================================================================
class Plan:
def __init__(self, rcfg):
self.raiz = os.path.normpath(rcfg["ruta"])
self.cliente = rcfg["cliente"]
self.tipo = rcfg.get("tipo", "contenido")
self.dicc = DICC["carpetas"]
self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)]
self.filas = []
self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos
self.con = db()
self.avisos = []
self.dias_mes = self._dias_de_rodaje()
def _dias_de_rodaje(self):
"""Para cada mes, los días distintos en que se grabó algo en esta carpeta de cliente.
Sirve para decidir si el mes necesita carpetas por día. Medido el 16-sep-2026 sobre el material
real: David Reyes tiene un solo día de rodaje en 7 de sus 8 meses (ahí la carpeta del día sería
estorbo), mientras que Danney tiene 36 meses con varios días y Camilo llega a 13 días distintos
en un mismo mes, 907 archivos mezclados en la misma carpeta."""
dias = defaultdict(set)
for f in self.con.execute(
"SELECT fecha FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN "
"('camara_raw','camara_video','camara_sidecar','audio_grabado','celular_video')", (self.raiz,)):
f0 = f["fecha"]
try:
dias[(int(f0[:4]), int(f0[5:7]))].add(f0)
except (TypeError, ValueError):
pass
return dias
def dia_de(self, fecha, anio, mes):
"""El nombre de la carpeta del día, o "" si en ese mes solo se grabó un día: entonces sobra."""
if len(self.dias_mes.get((anio, mes), ())) < 2:
return ""
return R.nombre_dia(fecha)
# ---- helpers ----
def rel(self, ruta):
return os.path.relpath(ruta, self.raiz)
def abs_(self, rel):
return os.path.normpath(os.path.join(self.raiz, rel))
def regla_para(self, ruta):
rel = R.norm(self.rel(ruta))
for r in self.reglas:
if R.norm(r.get("origen", "")) == rel:
return r
return None
def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None,
marcado=None, kw_destinos=None):
destino = os.path.normpath(destino)
if archivos is None and os.path.normpath(origen).lower() == destino.lower():
# el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» ->
# «10- Octubre»); antes se descartaba también ese, y el plan prometía meses «con nombre
# completo» que nunca se normalizaban
if accion != "renombrar" or os.path.normpath(origen) == destino:
return
solo_mayusculas = os.path.normpath(origen).lower() == destino.lower()
if n is None or bytes_ is None:
if archivos is not None:
n = len(archivos)
bytes_ = sum(a[1] for a in archivos)
else:
fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone()
n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0)
aviso_colision = ""
if archivos is not None and accion != "renombrar":
# también para los grupos de archivos: si el destino ya tiene un archivo con ese nombre,
# mover chocaría. Antes solo se comprobaba en las filas de carpeta.
especiales = kw_destinos or {}
# solo los primeros: cada comprobación es una consulta al disco de red, y para avisar
# basta con encontrar uno
choques = [os.path.basename(a[0]) for a in archivos[:40]
if os.path.isfile(especiales.get(a[0]) or os.path.join(destino, os.path.basename(a[0])))]
if choques:
aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre "
f"(«{choques[0]}»): esos no se moverían")
confianza, marcado = "baja", False
elif archivos is None and accion != "renombrar" and os.path.isfile(destino):
aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá"
confianza, marcado = "baja", False
elif archivos is None and not solo_mayusculas and os.path.isdir(destino) and os.path.isdir(origen):
aviso_colision = " · la carpeta destino ya existe: se fusionan"
self.filas.append({
"id": len(self.filas) + 1,
"origen": origen,
"origen_rel": self.rel(origen) if archivos is None else self.rel(origen),
"destino": destino,
"destino_rel": self.rel(destino),
"cubeta": cubeta,
"accion": accion,
"motivo": motivo + aviso_colision,
"confianza": confianza,
"n": n,
"bytes": bytes_,
"archivos": [a[0] for a in archivos] if archivos else None,
"marcado": (confianza in ("alta", "media")) if marcado is None else marcado,
"cosmetico": marcado is False and accion == "renombrar",
# en qué paso del recorrido se aplica: 1 limpiar (basura/proxies), 2 por fecha (años, meses, grabaciones,
# piezas), 3 el resto (exportados, proyectos, marca, música, dudosos)
"paso": "limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto",
})
def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
clave = (cubeta, destino_rel, motivo, confianza, os.path.normcase(os.path.dirname(fila["ruta"])))
self.sueltos[clave].append((fila["ruta"], fila["bytes"]))
def volcar_sueltos(self):
for (cubeta, destino_rel, motivo, confianza, _dir), archivos in self.sueltos.items():
origen = os.path.dirname(archivos[0][0])
self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos)
self.sueltos.clear()
# ---- niveles ----
def correr(self):
self.plan_dir(self.raiz, "raiz", None, None)
self.volcar_sueltos()
self.plan_basura()
self.con.close()
# primero las filas de archivos sueltos (rutas exactas), luego carpetas de más profunda a menos,
# para que un renombrado de mes no deje rutas viejas en filas que aún no se aplicaron
self.filas.sort(key=lambda f: (0 if f["archivos"] else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
self.marcar_colisiones()
for i, f in enumerate(self.filas, 1):
f["id"] = i
return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos}
def marcar_colisiones(self):
"""Dos movimientos del mismo plan que acaban en el mismo archivo. La comprobación de `agregar`
solo mira el disco tal como está ahora, así que no ve lo que van a hacer las demás filas.
Caso típico: «Grabacion mañana» y «Grabacion tarde» del mismo mes van las dos a
00_Grabacion/SONY, y las dos tarjetas traen un C0001.MP4. Antes chocaban al aplicar."""
destinos = {} # archivo destino (en minúsculas) -> primera fila que lo reclama
carpetas = {} # carpeta destino -> primera fila que la reclama (solo renombrados)
for f in self.filas:
if f["archivos"]:
especiales = f.get("destinos_archivo") or {}
for a in f["archivos"]:
d = (especiales.get(a) or os.path.join(f["destino"], os.path.basename(a))).lower()
otra = destinos.get(d)
if otra is not None and otra is not f:
self.avisar_colision(f, otra, os.path.basename(d))
break
destinos.setdefault(d, f)
elif f["accion"] == "renombrar":
d = f["destino"].lower()
otra = carpetas.get(d)
if otra is not None:
self.avisar_colision(f, otra, os.path.basename(f["destino"]))
else:
carpetas[d] = f
def avisar_colision(self, fila, otra, que):
aviso = f" · OJO: «{otra['origen_rel']}» también va a «{fila['destino_rel']}» y las dos traen «{que}»"
if aviso not in fila["motivo"]:
fila["motivo"] += aviso
fila["confianza"] = "baja"
fila["marcado"] = False
self.avisos.append(f"«{fila['origen_rel']}» y «{otra['origen_rel']}» acabarían las dos en "
f"«{fila['destino_rel']}» con archivos del mismo nombre ({que}). Se dejan sin marcar: "
f"cámbiale el destino a una de las dos, o aplícalas por separado.")
def entradas(self, ruta):
try:
with os.scandir(ruta) as it:
ents = [e for e in it if e.name.lower() not in ("#recycle", "@eadir")]
except OSError as ex:
self.avisos.append(f"No se pudo leer {ruta}: {ex}")
return [], []
dirs = sorted([e for e in ents if e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower())
files = sorted([e for e in ents if not e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower())
return dirs, files
def plan_dir(self, ruta, nivel, anio, mes):
dirs, files = self.entradas(ruta)
# numeración de piezas dentro de un mes
max_num = 0
if nivel == "mes":
for e in dirs:
p = R.detectar_pieza(e.name)
if p:
max_num = max(max_num, p[0])
for e in dirs:
self.plan_subdir(e, nivel, anio, mes, ruta, max_num)
if nivel == "mes" and not R.detectar_pieza(e.name):
cub, _ = R.cubeta_carpeta(e.name, self.dicc)
if cub is None:
max_num += 1
for e in files:
self.plan_archivo(e, nivel, anio, mes, ruta)
def plan_subdir(self, e, nivel, anio, mes, padre, max_num):
nombre = e.name
nn = R.norm(nombre)
regla = self.regla_para(e.path)
if regla:
if regla.get("destino") in ("", None, "IGNORAR"):
return
self.agregar(e.path, self.abs_(regla["destino"]), regla.get("cubeta", "REVISAR"), "mover carpeta",
"regla aprendida", "alta")
return
if nn in R.CARPETAS_PROPIAS or nombre.startswith("_"):
return # estructura ya canónica
if R.es_basura_dir(nombre):
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "BASURA", rel=self.rel(e.path))),
"BASURA", "papelera", "carpeta de caché / autoguardado", "alta")
return
if R.es_proxy_dir(nombre):
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "PROXY", rel=self.rel(e.path))),
"PROXY", "papelera", "proxies: se regeneran desde Resolve/Premiere", "alta")
return
a = R.detectar_anio(nombre)
if a and nivel in ("raiz", "contenedor", "grabacion"):
if nivel == "raiz":
self.plan_dir(e.path, "anio", a, None)
else:
# un año dentro de un contenedor: mirar adentro con el año fijado
self.plan_dir(e.path, "contenedor", a, None)
return
if nivel == "anio":
m, conflicto = R.detectar_mes(nombre)
if m:
canon = R.nombre_mes(m)
real, real_origen = self.mes_real(e.path)
discrepa = bool(real and anio and _discrepa_mes("%04d-%02d-01" % real, real_origen, anio, m))
if nombre != canon:
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "")
if discrepa:
motivo += (f" · ojo: lo que hay dentro se grabó en {R.nombre_mes(real[1])}, y se archiva "
f"en ese mes aunque la carpeta se llame así")
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo,
"media" if (conflicto or discrepa) else "alta")
self.plan_dir(e.path, "mes", anio, m)
return
if nivel == "raiz" or (nivel == "contenedor" and not anio):
# un mes suelto ("marzo", "Marzo 2026", "03- Marzo") fuera de su carpeta de año
sin_anio = re.sub(r"20\d\d", " ", nombre).strip(" -_")
m, conflicto = R.detectar_mes(sin_anio)
if m:
m_anio = re.search(r"(20\d\d)", nombre)
fa = int(m_anio.group(1)) if m_anio else fecha_carpeta(self.con, e.path)[0]
hoy = datetime.date.today()
if fa and (fa, m) > (hoy.year, hoy.month):
fa -= 1
if fa:
canon = self.abs_(os.path.join(str(fa), R.nombre_mes(m)))
self.agregar(e.path, canon, "MES", "mover carpeta",
f"mes suelto fuera de su año: entra en {fa}", "media")
self.plan_dir(e.path, "mes", fa, m)
return
if nivel == "contenedor" and anio:
m, conflicto = R.detectar_mes(nombre)
if m:
# un mes guardado dentro de "Nuevo 2026": sale al año que le toca
canon = self.abs_(os.path.join(str(anio), R.nombre_mes(m)))
self.agregar(e.path, canon, "MES", "mover carpeta", f"mes guardado dentro de «{os.path.basename(padre)}»", "media")
self.plan_dir(e.path, "mes", anio, m)
return
if nivel == "mes":
p = R.detectar_pieza(nombre)
if p:
cub_t, _ = R.cubeta_carpeta(p[1], self.dicc)
if cub_t == "GRABACION": # "03 - Grabacion Agosto": es la sesión del mes, no una pieza
self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación")
return
canon = R.nombre_pieza(p[0], limpiar_nombre(p[1]))
if nombre != canon:
self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar",
"cosmético: nombre de pieza a «NN- Título» (sin marcar por defecto)", "media", marcado=False)
return # dentro de una pieza no se toca
cub, motivo = R.cubeta_carpeta(nombre, self.dicc)
if cub == "IGNORAR":
return
if cub == "CONTENEDOR":
m_anio = re.search(r"(20\d\d)", nombre)
self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else anio, mes)
return
if cub == "REVISAR":
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "REVISAR", nombre=nombre)), "REVISAR",
"mover carpeta", motivo, "baja")
return
if cub == "GRABACION":
self.plan_grabacion(e, nivel, anio, mes, motivo)
return
generico = nn in self.dicc
limpio = limpiar_nombre(nombre)
p = R.detectar_pieza(nombre)
if p and cub in ("CAMPANA", "PIEZA", None):
limpio = limpiar_nombre(p[1]) # "01- Dove" -> "Dove"
sub = "" if generico else limpio
fa, fm, fconf, fmot = fecha_carpeta(self.con, e.path)
con_fecha = cub in ("PIEZA", "CAMPANA", "EXPORTADOS", "PORTADAS", "VOZ", "IA", "GUION", "FOTOS", "CELULAR", None)
if anio:
fa = anio
if mes:
fm = mes
elif fconf == "baja" and cub not in ("PIEZA", None):
fm = None # fechas mezcladas: se queda a nivel de año ("2025/_Exportados")
fconf = "media" if fa else "baja"
hoy = datetime.date.today()
if fa and fm and (fa, fm) > (hoy.year, hoy.month):
fa -= 1 # un mes en el futuro no existe: era del año pasado
if cub is None:
filas_c = [f for f in archivos_bajo(self.con, e.path) if not f["es_basura"] and not f["es_proxy"]]
cam = [f for f in filas_c if f["fuente"] in R.FUENTE_CARPETA
or f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado")]
if filas_c and len(cam) >= 0.8 * len(filas_c):
self.plan_grabacion(e, nivel, anio, mes, "carpeta sin nombre reconocido pero llena de clips de cámara")
return
if nivel == "mes":
canon = R.nombre_pieza(max_num + 1, limpio)
self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar",
"carpeta sin número dentro del mes: se numera como pieza", "media")
return
cub = "PIEZA"
motivo = "no reconocida: se propone como pieza por la fecha de sus archivos"
fconf = "baja"
sub = limpio
if con_fecha and not (anio and mes):
conf = fconf
motivo = f"{motivo}; {fmot}"
else:
conf = "alta" if generico else "media"
if cub == "MARCA":
sub = R.MARCA_SUB.get(nn, sub or limpio)
conf = "alta" if generico or nn in R.MARCA_SUB else "media"
if cub in ("PIEZA", "CAMPANA", "PROYECTOS"):
sub = sub or limpio
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path))
self.agregar(e.path, self.abs_(destino), cub, "mover carpeta", motivo, conf)
def mes_real(self, ruta):
"""((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de
acuerdo. (None, "") cuando la fecha solo sale de la fecha de modificación, que no es de fiar."""
cnt = Counter()
origenes = Counter()
for f in archivos_bajo(self.con, ruta):
if f["es_basura"] or f["es_proxy"] or f["fecha_origen"] == "mtime":
continue
if f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "foto_camara", "celular_video", "audio_grabado"):
cnt[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
origenes[f["fecha_origen"]] += 1
if not cnt:
return None, ""
(a, m), n = cnt.most_common(1)[0]
if n < 3 or n / sum(cnt.values()) < 0.7:
return None, ""
return (a, m), origenes.most_common(1)[0][0]
def plan_grabacion(self, e, nivel, anio, mes, motivo):
"""Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
nombre = e.name
nn = R.norm(nombre)
generico = nn in self.dicc or nn in ("grabacion", "grabado", "grabaciones")
filas = archivos_bajo(self.con, e.path)
if not filas:
return
grupos = defaultdict(list)
desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
for f in filas:
if f["es_basura"] or f["es_proxy"]:
continue
fuente = R.FUENTE_CARPETA.get(f["fuente"], None)
if fuente is None:
if f["rol"] in ("audio", "audio_grabado"):
fuente = "AUDIO"
elif f["rol"] in ("video", "camara_video"):
fuente = "OTROS"
elif f["rol"] in ("imagen", "foto_camara", "celular_foto"):
fuente = "FOTOS"
else:
fuente = "OTROS"
# año / mes que digan las carpetas intermedias (p. ej. "Blackmagic (BRAW)/2023/…")
anio_ruta, mes_ruta = None, None
for seg in os.path.relpath(f["dir"], e.path).split(os.sep):
if R.detectar_anio(seg):
anio_ruta = R.detectar_anio(seg)
elif R.detectar_mes(seg)[0]:
mes_ruta = R.detectar_mes(seg)[0]
if anio and mes:
# La carpeta dice un mes y el archivo puede decir otro. Medido el 16-sep-2026 sobre el
# material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes que
# anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre de 2025).
#
# Manda el archivo, no la carpeta: el nombre lo pone la cámara y la fecha de creación la
# pone el disco, mientras que el nombre de la carpeta lo escribió una persona con prisa.
# Así no hay que preguntarle a nadie cuál es el mes bueno.
ay, am, fconf = anio, mes, "alta"
if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
ra, rm = int(f["fecha"][:4]), int(f["fecha"][5:7])
if f["fecha_origen"] == "nombre+mtime":
ra = anio if rm <= mes else anio - 1 # el nombre de la cámara no trae año
ay, am, fconf = ra, rm, "media"
desacuerdo[(ra, rm)] += 1
else:
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado»
fconf = "media" if por_nombre else "baja"
# el año de una carpeta intermedia ("…/2023/…") manda sobre la fecha de modificación,
# pero no sobre una fecha escrita en el nombre del archivo
if anio_ruta and not por_nombre:
ay = anio_ruta
fconf = "media"
if anio and not por_nombre:
ay = anio
if mes_ruta:
am = mes_ruta
fconf = "media"
hoy = datetime.date.today()
if (ay, am) > (hoy.year, hoy.month):
ay -= 1
grupos[(ay, am, self.dia_de(f["fecha"], ay, am), fuente, fconf)].append((f["ruta"], f["bytes"]))
etiqueta = "" if generico else R.etiqueta_grabacion(nombre)
aviso_fecha = ""
# hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
# toda la carpeta sin marcar.
n_fechables = sum(1 for f in filas if not f["es_basura"] and not f["es_proxy"] and f["fecha_origen"] != "mtime")
if desacuerdo and sum(desacuerdo.values()) >= 3 and sum(desacuerdo.values()) >= 0.25 * max(1, n_fechables):
(da, dm), cuantos = desacuerdo.most_common(1)[0]
aviso_fecha = (f" · la carpeta decía {R.nombre_mes(mes)}, pero {cuantos} de sus archivos son de "
f"{R.nombre_mes(dm)} de {da}: van a su mes de verdad")
self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de "
f"sus archivos se grabaron en {R.nombre_mes(dm)} de {da}. Se archivan en el mes en que "
f"se grabaron, no en el que dice la carpeta.")
for (ay, am, dia, fuente, fconf), archivos in grupos.items():
sub = fuente if not etiqueta else os.path.join(etiqueta, fuente)
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=dia, sub=sub,
nombre=limpiar_nombre(nombre))
# si todos ya están en esa carpeta, no hay nada que hacer
if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
continue
mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else ""))
if dia:
mot += " · y por día, que ese mes hubo varios rodajes"
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf,
archivos=archivos)
def plan_archivo(self, e, nivel, anio, mes, padre):
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
if not fila:
return
if fila["es_basura"] or fila["es_proxy"]:
return # los recoge plan_basura
if nivel == "pieza":
return
rol = fila["rol"]
cub = R.ROL_A_CUBETA.get(rol, "REVISAR")
conf = "media"
motivo = f"archivo suelto ({rol})"
fa, fm = int(fila["fecha"][:4]), int(fila["fecha"][5:7])
if anio and fila["fecha_origen"] == "mtime":
fa = anio
if mes:
# el archivo manda sobre la carpeta cuando su propia fecha es de fiar, igual que dentro de una
# carpeta de grabación: si no, un clip de diciembre traspapelado en la carpeta de marzo se
# quedaba archivado en marzo, que es justo lo que 2.2.0 dejó de hacer con las grabaciones.
if _discrepa_mes(fila["fecha"], fila["fecha_origen"], anio or fa, mes):
if fila["fecha_origen"] == "nombre+mtime" and anio:
fa = anio if fm <= mes else anio - 1 # el nombre de la cámara no trae año
conf = "media"
motivo += (f" · la carpeta dice {R.nombre_mes(mes)} y el archivo se grabó en "
f"{R.nombre_mes(fm)}: manda el archivo")
else:
fm = mes
elif fila["fecha_origen"] == "mtime":
conf = "baja"
motivo += " · mes por fecha de modificación"
hoy = datetime.date.today()
if (fa, fm) > (hoy.year, hoy.month):
fa -= 1
sub = ""
if cub == "GRABACION":
sub = R.FUENTE_CARPETA.get(fila["fuente"], "OTROS")
conf = "alta" if (anio and mes) else conf
if cub == "CELULAR":
if self.tipo == "contenido":
cub = "GRABACION"
sub = "CELULAR"
else:
sub = fila["fecha"][:7] if not (anio and mes) else ""
if cub == "PORTADAS" and nivel not in ("mes",):
cub = "REVISAR" if rol == "imagen" and nivel == "raiz" else cub
conf = "baja" if cub == "REVISAR" else conf
if cub == "EXPORTADOS":
conf = "media" if nivel == "mes" else "baja"
motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "")
if cub == "PROYECTOS":
# el programa manda sobre el rol: un .aep y un .psd sueltos en la misma carpeta van cada uno
# a la suya, que es justo lo que cuesta encontrar cuando están mezclados
sub = R.app_proyecto(fila["ext"]) or ("Diseño" if rol == "diseno" else "Edicion")
motivo = f"proyecto de {sub} suelto" if R.app_proyecto(fila["ext"]) else "proyecto de edición / diseño suelto"
conf = "alta"
if cub == "MUSICA" and rol == "audio":
conf = "baja"
if cub == "REVISAR":
destino = "_Revisar"
elif cub == "PROYECTOS":
destino = destino_plantilla(self.tipo, "PROYECTOS", app=sub)
else:
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="",
dia=self.dia_de(fila["fecha"], fa, fm) if cub in ("GRABACION", "CELULAR") else "")
self.suelto(fila, cub, destino, motivo, conf)
def plan_basura(self):
con = self.con
# carpetas enteras de basura / proxy ya salieron en plan_subdir si estaban a la vista;
# aquí van las que están más adentro (dentro de piezas) y los archivos sueltos de basura
carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall()
cubiertas = []
for c in sorted(carpetas, key=lambda c: len(c["ruta"])):
if any(c["ruta"].lower().startswith(p.lower() + os.sep) for p in cubiertas):
continue
if any(os.path.normpath(f["origen"]).lower() == c["ruta"].lower() for f in self.filas):
cubiertas.append(c["ruta"])
continue
if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue
cub = "PROXY" if c["es_proxy"] else "BASURA"
self.agregar(c["ruta"], self.abs_(destino_plantilla(self.tipo, cub, rel=self.rel(c["ruta"]))), cub, "papelera",
"proxies: se regeneran" if cub == "PROXY" else "caché / autoguardado", "alta")
cubiertas.append(c["ruta"])
sueltos = con.execute("SELECT * FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall()
grupos = defaultdict(list)
for f in sueltos:
if any(f["dir"].lower() == p.lower() or f["dir"].lower().startswith(p.lower() + os.sep) for p in cubiertas):
continue
if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue
cub = "PROXY" if f["es_proxy"] else "BASURA"
grupos[cub].append(f)
# una sola fila por tipo: cada archivo conserva su ruta relativa dentro de _Para borrar
for cub, fs in grupos.items():
destinos = {}
for f in fs:
destinos[f["ruta"]] = self.abs_(os.path.join(destino_plantilla(self.tipo, cub, rel=self.rel(f["dir"])), f["nombre"]))
archivos = [(f["ruta"], f["bytes"]) for f in fs]
self.agregar(self.raiz, self.abs_(destino_plantilla(self.tipo, cub, rel="")), cub, "papelera",
f"basura de sistema en {len(set(f['dir'] for f in fs))} carpetas (.DS_Store, Thumbs.db, .tmp, ._*)" if cub == "BASURA" else "proxies sueltos",
"alta", archivos=archivos)
self.filas[-1]["destinos_archivo"] = destinos
self.filas[-1]["origen_rel"] = "(toda la raíz)"
# ==========================================================================
# Aplicar el plan (con bitácora y deshacer)
# ==========================================================================
def bitacora(entrada):
entrada["ts"] = time.time()
with open(BITACORA_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entrada, ensure_ascii=False) + "\n")
def leer_bitacora(limite=2000):
if not os.path.exists(BITACORA_PATH):
return []
with open(BITACORA_PATH, "r", encoding="utf-8") as f:
lineas = f.readlines()
out = []
for l in lineas[-limite:]:
try:
out.append(json.loads(l))
except ValueError:
pass
return out
def nombre_libre(destino):
if not os.path.exists(destino):
return destino
base, ext = os.path.splitext(destino)
i = 2
while os.path.exists(f"{base} ({i}){ext}"):
i += 1
return f"{base} ({i}){ext}"
def mover(origen, destino, lote, movidos):
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
origen = os.path.normpath(origen)
destino = os.path.normpath(destino)
if origen == destino:
return
if origen.lower() == destino.lower():
# solo cambian mayúsculas («10- octubre» -> «10- Octubre»). En Windows es el mismo nombre, así
# que isdir/exists del destino dicen que sí y el camino normal lo tomaría por una fusión de una
# carpeta consigo misma. Antes se devolvía sin hacer nada y el plan prometía un renombrado que
# nunca ocurría: os.rename sí sabe hacerlo en NTFS.
os.rename(origen, destino)
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, destino))
return
if os.path.isdir(origen) and os.path.isdir(destino):
# fusión: cada hijo va por su cuenta. Si uno tropieza (ya existe con ese nombre, está abierto),
# los demás siguen; antes el primero que fallaba cortaba el bucle y la carpeta quedaba medio
# vaciada, con el aviso hablando de un solo archivo.
fallos = []
for nombre in os.listdir(origen):
try:
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
except OSError as ex:
fallos.append(f"{nombre}: {ex}")
try:
os.rmdir(origen)
bitacora({"lote": lote, "op": "rmdir", "de": origen})
except OSError:
pass
if fallos:
raise OSError(f"{len(fallos)} de «{os.path.basename(origen)}» se quedaron: " + "; ".join(fallos[:3])
+ (f"; y {len(fallos) - 3} más" if len(fallos) > 3 else ""))
return
os.makedirs(os.path.dirname(destino), exist_ok=True)
if os.path.exists(destino):
# dentro de _Para borrar o Duplicados el nombre da igual: se numera. Fuera, NUNCA se renombra
# (Resolve/Premiere enlazan por nombre y los sidecar Sony van emparejados): se deja donde está y se avisa.
if _fuera_de_dup(destino):
raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo")
destino = nombre_libre(destino)
try:
os.rename(origen, destino)
except OSError:
# entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen
if os.path.isdir(origen):
shutil.move(origen, destino)
else:
shutil.copy2(origen, destino)
if os.path.getsize(destino) != os.path.getsize(origen):
os.remove(destino)
raise OSError(f"la copia de «{os.path.basename(origen)}» quedó incompleta; el original no se toca")
try:
os.remove(origen)
except OSError as ex:
os.remove(destino)
raise OSError(f"«{os.path.basename(origen)}» está en uso (¿abierto en Resolve o Premiere?); no se mueve") from ex
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, destino))
APLICANDO = {"hechas": 0, "total": 0, "actual": ""}
def aplicar(raiz, filas):
lote = time.strftime("%Y%m%d-%H%M%S")
movidos = []
errores = []
hechas = 0
filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz))
for f in filas:
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "")
if f.get("archivos"):
# cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los
# ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía
# que había fallado uno solo.
especiales = f.get("destinos_archivo") or {}
fallos_fila = 0
try:
if not especiales:
os.makedirs(f["destino"], exist_ok=True)
except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}")
continue
for a in f["archivos"]:
if not os.path.exists(a):
continue
try:
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
except Exception as ex: # noqa
fallos_fila += 1
if fallos_fila <= 5:
errores.append(f"{os.path.basename(a)}: {ex}")
if fallos_fila > 5:
errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover")
if not fallos_fila:
hechas += 1
else:
try:
if os.path.exists(f["origen"]):
mover(f["origen"], f["destino"], lote, movidos)
hechas += 1
else:
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: {ex}")
APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
for f in filas:
base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"])
if os.path.normpath(base).lower() != os.path.normpath(raiz).lower():
quitar_vacias(base, raiz)
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores})
return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores}
def actualizar_inventario(movidos):
"""Refleja en la base de datos los movimientos hechos, sin re-escanear.
Las filas de `carpetas` se reescriben con su ruta nueva en vez de borrarse: la interfaz recalcula el
plan tras cada paso SIN volver a escanear, y si el subárbol movido desaparecía del inventario, los
pasos siguientes salían con «0 archivos · 0 B» y sin fecha. La marca de basura/proxy heredada de una
carpeta (por ejemplo lo que cuelga de «Adobe Premiere Pro Auto-Save») también se conserva: antes se
recalculaba desde cero y se perdía."""
con = db()
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
with con:
for de, a in movidos:
en_papelera = 1 if any(m in a for m in marcas) else 0
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, "
"es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(a, os.path.dirname(a), os.path.basename(a), en_papelera, de))
pref = de + os.sep
for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).fetchall():
nueva = a + r["ruta"][len(de):]
con.execute("UPDATE archivos SET ruta = ?, dir = ?, "
"es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(nueva, os.path.dirname(nueva), en_papelera, r["ruta"]))
# las carpetas siguen al material: se mueven de ruta, no se borran
viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'",
(de, esc_like(pref) + "%")).fetchall()
con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%"))
if os.path.isdir(a):
for v in viejas:
nueva = a if v["ruta"] == de else a + v["ruta"][len(de):]
fila = dict(v)
fila["ruta"] = nueva
fila["nombre"] = os.path.basename(nueva)
if en_papelera:
fila["es_basura"] = 1
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
fila["es_proxy"], fila["es_basura"]))
_recontar_carpetas(con, movidos)
con.close()
def _recontar_carpetas(con, movidos):
"""Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no
existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los
colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del
escaneo."""
tocadas = set()
for de, a in movidos:
for ruta in (os.path.dirname(de), os.path.dirname(a), a if os.path.isdir(a) else None):
if not ruta:
continue
raiz = _raiz_de(ruta)
if not raiz:
continue
d = os.path.normpath(ruta)
while d.lower().startswith(raiz.lower()) and d.lower() != raiz.lower():
tocadas.add((d, raiz))
padre = os.path.dirname(d)
if padre == d:
break
d = padre
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
for d, raiz in tocadas:
if not os.path.isdir(d):
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
continue
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'",
(d, esc_like(d + os.sep) + "%")).fetchone()
previa = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (d,)).fetchone()
en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(d, raiz, os.path.basename(d), fila["n"], fila["b"],
previa["anio_moda"] if previa else None, previa["mes_moda"] if previa else None,
previa["frac_moda"] if previa else 0, previa["dias_rango"] if previa else 0,
previa["es_proxy"] if previa else (1 if R.es_proxy_dir(os.path.basename(d)) else 0),
1 if en_papelera else (previa["es_basura"] if previa else 0)))
def _raiz_de(ruta):
"""La carpeta de cliente registrada a la que pertenece una ruta, o None."""
r = os.path.normpath(ruta).lower()
for c in CONFIG.get("raices", []):
k = os.path.normpath(c["ruta"]).lower()
if r == k or r.startswith(k + os.sep):
return os.path.normpath(c["ruta"])
return None
def quitar_vacias(ruta, tope):
"""borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)"""
ruta = os.path.normpath(ruta)
tope = os.path.normpath(tope)
while ruta.lower() != tope.lower() and ruta.lower().startswith(tope.lower()):
try:
if os.path.isdir(ruta) and not os.listdir(ruta):
os.rmdir(ruta)
else:
break
except OSError:
break
ruta = os.path.dirname(ruta)
def _sin_archivos(ruta):
"""True si debajo de `ruta` no hay un solo archivo ni ningún enlace: nada que perder al borrarla.
Un enlace o una unión de Windows cuentan como contenido aunque no sean archivos: apuntan a otro
árbol y borrarlos sí perdería algo."""
try:
with os.scandir(ruta) as it:
for e in it:
if _es_enlace(e) or not e.is_dir(follow_symlinks=False):
return False
if not _sin_archivos(e.path):
return False
except OSError:
return False # no se puede leer: se da por ocupada
return True
def carpetas_vacias(raiz):
"""Carpetas sin un solo archivo dentro, en todo el árbol.
Ordenar deja huecos: repartida «Grabacion» por mes y por cámara, la carpeta original se queda sin
nada. `quitar_vacias` solo limpia el camino de lo que se acaba de mover, así que lo que ya estaba
vacío antes se queda ahí para siempre.
Los candidatos salen del inventario (instantáneo: una carpeta está vacía si ninguna ruta de
`archivos` cuelga de ella) y cada uno se comprueba en el disco antes de enseñarlo, porque el
inventario puede tener días. Solo se devuelve la de más arriba de cada rama: si «2026\03- Marzo»
está vacía entera, no hace falta enumerar sus seis subcarpetas."""
raiz = _clave(raiz)
con = db()
carpetas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con_archivos = set()
for r in con.execute("SELECT DISTINCT dir FROM archivos WHERE raiz = ?", (raiz,)).fetchall():
d = os.path.normpath(r["dir"])
while d and d.lower().startswith(raiz.lower()): # el directorio y todos sus padres tienen algo
if d.lower() in con_archivos:
break
con_archivos.add(d.lower())
padre = os.path.dirname(d)
if padre == d:
break
d = padre
con.close()
vacias = sorted({os.path.normpath(c) for c in carpetas
if os.path.normpath(c).lower() not in con_archivos
and os.path.normpath(c).lower() != raiz.lower()}, key=lambda x: (len(x), x.lower()))
out, tomadas = [], []
for c in vacias:
if any(c.lower().startswith(t + os.sep) for t in tomadas):
continue # ya cuelga de otra que se va entera
if not os.path.isdir(c) or not _sin_archivos(c):
continue # ya no existe, o alguien dejó algo dentro
dentro = sum(len(d) for _, d, _ in os.walk(c))
out.append({"ruta": c, "rel": os.path.relpath(c, raiz), "subcarpetas": dentro})
tomadas.append(c.lower())
return {"raiz": raiz, "carpetas": out, "n": len(out)}
def borrar_vacias(raiz, rutas):
"""Borra carpetas vacías. Es lo único que la app borra, y solo porque no hay nada dentro que
perder: cada carpeta queda apuntada en la bitácora y «Deshacer» las vuelve a crear."""
lote = "vacias-" + time.strftime("%Y%m%d-%H%M%S")
raiz = _clave(raiz)
borradas, errores = 0, []
for r in sorted({os.path.normpath(x) for x in rutas}, key=lambda x: -len(x)):
if not dentro_de(raiz, r) or r.lower() == raiz.lower():
errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca")
continue
if not os.path.isdir(r):
continue # ya no está: nada que hacer
if not _sin_archivos(r):
errores.append(f"{os.path.basename(r)}: ya no está vacía, se deja como está")
continue
try:
for base, _, _ in sorted(os.walk(r), key=lambda x: -len(x[0])):
os.rmdir(base)
bitacora({"lote": lote, "op": "rmdir", "de": base})
borradas += 1
except OSError as ex:
errores.append(f"{os.path.basename(r)}: {ex}")
if borradas:
con = db()
with con:
for e in leer_bitacora(100000):
if e.get("lote") == lote and e.get("op") == "rmdir":
con.execute("DELETE FROM carpetas WHERE ruta = ?", (e["de"],))
con.close()
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": borradas, "movimientos": borradas, "errores": errores})
return {"lote": lote, "borradas": borradas, "errores": errores}
def deshacer(lote):
todas = leer_bitacora(100000)
entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir")]
fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {})
raiz = fin.get("raiz") or ""
deshechos, errores, movidos, recreadas = 0, [], [], []
for e in reversed(entradas):
try:
if e["op"] == "rmdir":
os.makedirs(e["de"], exist_ok=True)
if lote.startswith("vacias-"):
# aquí la carpeta ERA el cambio, no el rastro de uno: se cuenta y vuelve al inventario
recreadas.append(e["de"])
deshechos += 1
else:
if not os.path.exists(e["a"]):
errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve")
elif e["a"].lower() == e["de"].lower():
os.rename(e["a"], e["de"]) # solo cambiaban las mayúsculas: exists() diría que está ocupado
movidos.append((e["a"], e["de"]))
deshechos += 1
elif os.path.exists(e["de"]):
errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve")
else:
os.makedirs(os.path.dirname(e["de"]), exist_ok=True)
try:
os.rename(e["a"], e["de"])
except OSError:
shutil.move(e["a"], e["de"]) # el respaldo copió entre discos: se vuelve de la misma forma
movidos.append((e["a"], e["de"]))
deshechos += 1
if raiz:
quitar_vacias(os.path.dirname(e["a"]), raiz)
except OSError as ex:
errores.append(f"{e.get('a')}: {ex}")
actualizar_inventario(movidos)
if recreadas and raiz:
con = db()
with con:
for d in recreadas:
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)",
(d, raiz, os.path.basename(d)))
con.close()
bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores})
return {"deshechos": deshechos, "errores": errores}
# ==========================================================================
# Vocabulario (lo que la app "ve" que usamos como nombres)
# ==========================================================================
def vocabulario(raiz):
con = db()
filas = con.execute("SELECT * FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()
con.close()
tokens = {}
for c in filas:
nombre = c["nombre"]
if os.path.normpath(c["ruta"]).lower() == os.path.normpath(raiz).lower():
continue
if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]:
continue
rel = os.path.relpath(c["ruta"], raiz)
padre = os.path.basename(os.path.dirname(c["ruta"]))
if R.detectar_pieza(nombre) and R.detectar_mes(padre)[0]:
continue
if R.norm(nombre) in R.CARPETAS_PROPIAS or nombre.startswith("_"):
continue
tok = R.norm(nombre)
if not tok or len(tok) > 60:
continue
# ¿está dentro de una pieza (carpeta numerada bajo un mes)? esas no se mueven
partes = rel.split(os.sep)[:-1]
dentro_pieza = any(R.detectar_pieza(partes[i]) and i > 0 and R.detectar_mes(partes[i - 1])[0] for i in range(len(partes)))
t = tokens.get(tok)
if t is None:
cub, motivo = R.cubeta_carpeta(nombre, DICC["carpetas"])
t = tokens[tok] = {"token": tok, "ejemplo": nombre, "n": 0, "archivos": 0, "bytes": 0, "rutas": [], "dentro_pieza": dentro_pieza,
"cubeta": cub or "", "motivo": motivo, "aprendido": tok in DICC["carpetas"] and tok not in R.DICCIONARIO_SEMILLA}
t["dentro_pieza"] = t["dentro_pieza"] and dentro_pieza
t["n"] += 1
t["archivos"] += c["n"] or 0
t["bytes"] += c["bytes"] or 0
if len(t["rutas"]) < 4:
t["rutas"].append(rel)
if tokens and any(not t["cubeta"] for t in tokens.values()):
con = db()
resumen = _resumen_carpetas(con, raiz)
con.close()
for t in tokens.values():
if not t["cubeta"]:
_sugerir(resumen, raiz, t)
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
return out
_ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición / diseño", "audio": "audio",
"imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"}
def _discrepa_mes(fecha, origen, anio, mes):
"""True si lo que dice el nombre del archivo no cuadra con el mes que anuncia la carpeta.
Los nombres de Blackmagic (A006_12171629) traen mes y día pero NO año: el año sale de la fecha de
modificación, que no es de fiar, así que en ese caso solo se compara el mes."""
if origen == "mtime":
return False # la fecha de modificación no prueba nada
ra, rm = int(fecha[:4]), int(fecha[5:7])
if origen == "nombre+mtime":
d = abs(rm - mes)
return min(d, 12 - d) >= 2 # solo el mes, y con dos meses de margen
return abs((ra * 12 + rm) - (anio * 12 + mes)) >= 2
def _clase_rol(rol):
return ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
"proyecto" if rol in ("proyecto", "diseno") else
"audio" if rol == "audio" else
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
"documento" if rol == "documento" else
"video" if rol in ("video", "celular_video") else "otro")
def _resumen_carpetas(con, raiz):
"""Para cada carpeta de la raíz, qué tipos de archivo tiene dentro contando subcarpetas.
Se calcula de una sola pasada sumando hacia arriba: antes se hacía una consulta por carpeta y
cada una recorría la tabla entera (abrir una raíz de 24.000 archivos tardaba 8,8 s; ahora 0,2 s)."""
agg = defaultdict(lambda: defaultdict(lambda: [0, 0]))
filas = con.execute("SELECT dir, rol, COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos "
"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol", (raiz,)).fetchall()
tope = os.path.normcase(os.path.normpath(raiz))
for f in filas:
k = _clase_rol(f["rol"])
d = os.path.normpath(f["dir"])
while True:
e = agg[os.path.normcase(d)][k]
e[0] += f["n"]
e[1] += f["b"]
if os.path.normcase(d) == tope:
break
padre = os.path.dirname(d)
if padre == d:
break
d = padre
return agg
def _sugerir(resumen, raiz, t):
"""Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre.
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible)."""
import difflib
conteo = defaultdict(lambda: [0, 0])
total = [0, 0]
for rel in t["rutas"][:3]:
for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items():
conteo[k][0] += n
conteo[k][1] += b
total[0] += n
total[1] += b
orden = sorted(conteo.items(), key=lambda kv: -kv[1][1])
t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas"
sug, conf, por_que = "", "", ""
# a) por el nombre: se parece a una palabra del diccionario
dicc = DICC["carpetas"]
cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82)
if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR"):
sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»"
# b) por el contenido (manda si es contundente)
if total[0] >= 3 and orden:
k, (n, b) = orden[0]
frac = b / total[1] if total[1] else n / total[0]
if frac >= 0.75:
por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
if por_cont:
if sug and sug != por_cont:
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
conf = "baja"
else:
# «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos
# los clientes: exige que sea contundente y que haya varias carpetas iguales.
seguro = frac >= 0.92 and n >= 8 and total[0] >= 10
sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que
# ==========================================================================
# Mapa
# ==========================================================================
def mapa(raiz):
con = db()
raiz = os.path.normpath(raiz)
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
if not esc:
con.close()
return {"escaneado": False}
q = lambda sql, *a: [dict(r) for r in con.execute(sql, a).fetchall()]
total = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ?", raiz)[0]
por_ext = q("SELECT ext, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY ext ORDER BY bytes DESC LIMIT 14", raiz)
por_rol = q("SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY rol ORDER BY bytes DESC", raiz)
por_fuente = q("SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND fuente != '' GROUP BY fuente ORDER BY bytes DESC", raiz)
por_anio = q("SELECT substr(fecha,1,4) anio, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY anio ORDER BY anio", raiz)
por_mes = q("SELECT substr(fecha,1,7) mes, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado') GROUP BY mes ORDER BY mes", raiz)
basura = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_basura = 1", raiz)[0]
proxies = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", raiz)[0]
dd = duplicados(raiz)
dup_ej = [{"tipo": g["tipo"], "nombre": g["nombre"], "bytes": g["bytes"], "copias": len(g["mueven"]) + 1,
"queda": os.path.relpath(g["queda"], raiz), "rutas": [os.path.relpath(r, raiz) for r in g["mueven"]]} for g in dd["grupos"][:12]]
raw = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND rol = 'camara_raw'", raiz)[0]
grandes = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND bytes > 1000000000 AND rol IN ('video','camara_video') AND es_proxy = 0", raiz)[0]
sin_fecha = q("SELECT COUNT(*) n FROM archivos WHERE raiz = ? AND fecha_origen = 'mtime' AND rol IN ('camara_raw','camara_video')", raiz)[0]
# árbol de 2 niveles para el treemap
hijos = q("SELECT * FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC",
raiz, esc_like(con_sep(raiz)) + "%", esc_like(con_sep(raiz)) + "%" + esc_like(os.sep) + "%")
arbol = []
for h in hijos:
nietos = q("SELECT nombre, n, bytes FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC LIMIT 40",
raiz, esc_like(h["ruta"] + os.sep) + "%", esc_like(h["ruta"] + os.sep) + "%" + esc_like(os.sep) + "%")
cub, _ = R.cubeta_carpeta(h["nombre"], DICC["carpetas"])
if R.detectar_anio(h["nombre"]):
cub = "ANIO"
elif R.norm(h["nombre"]) in R.PAPELERAS_NORM or R.norm(h["nombre"]) == R.norm(R.DUPLICADOS):
cub = "APARTADO" # carpetas de la propia app: no son «sin reconocer»
arbol.append({"nombre": h["nombre"], "n": h["n"], "bytes": h["bytes"], "cubeta": cub or "", "hijos": nietos,
"anio": h["anio_moda"], "mes": h["mes_moda"]})
sueltos_raiz = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, raiz)[0]
con.close()
return {
"escaneado": True, "raiz": raiz, "escaneo": dict(esc), "total": total, "por_ext": por_ext, "por_rol": por_rol,
"por_fuente": por_fuente, "por_anio": por_anio, "por_mes": por_mes, "basura": basura, "proxies": proxies,
"duplicados": {"grupos": len(dd["grupos"]), "n": dd["n"], "bytes": dd["bytes"], "identicos": dd["identicos"], "versiones": dd["versiones"], "ejemplos": dup_ej}, "raw": raw, "grandes": grandes,
"sin_fecha": sin_fecha, "arbol": arbol, "sueltos_raiz": sueltos_raiz,
}
def buscar_carpeta(nombre, profundidad=3):
"""Busca una carpeta por nombre en todos los discos (hasta 3 niveles), para el arrastrar-y-soltar."""
objetivo = R.norm(nombre)
if not objetivo:
return []
saltar = {"#recycle", "@eadir", "windows", "program files", "program files (x86)", "programdata", "$recycle.bin", "appdata", "node_modules", "system volume information"}
hits = []
inicio = time.time()
def rec(path, nivel):
if time.time() - inicio > 25 or len(hits) >= 20:
return
try:
with os.scandir(path) as it:
ents = [e for e in it if e.is_dir(follow_symlinks=False) and not e.name.startswith((".", "$", "~"))]
except OSError:
return
for e in ents:
if R.norm(e.name) == objetivo:
hits.append(e.path)
if nivel < profundidad:
for e in ents:
if e.name.lower() not in saltar:
rec(e.path, nivel + 1)
for letra in "DEFGHIJKLMNOPQRSTUVWXYZC":
raiz = letra + ":" + os.sep
if os.path.exists(raiz):
rec(raiz, 1)
return hits
_DISCOS_CACHE = {"ts": 0, "datos": None}
def discos():
if time.time() - _DISCOS_CACHE["ts"] < 120 and _DISCOS_CACHE["datos"]:
return _DISCOS_CACHE["datos"]
unc = {}
try:
out = subprocess.run(["net", "use"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA).stdout
for l in out.splitlines():
m = re.search(r"\b([A-Z]):\s+(\\\\\S+)", l)
if m:
unc[m.group(1) + ":"] = m.group(2)
except Exception: # noqa
pass
alias = {}
for cli, info in CONFIG["clientes"].items():
for a in info.get("alias", []) + [cli]:
alias[R.norm(a)] = cli
res = []
for letra in "CDEFGHIJKLMNOPQRSTUVWXYZ":
d = letra + ":"
raiz = d + os.sep
if not os.path.exists(raiz):
continue
try:
u = shutil.disk_usage(raiz)
except OSError:
continue
carpetas = []
try:
with os.scandir(raiz) as it:
for e in it:
if e.is_dir(follow_symlinks=False) and not e.name.startswith(("#", ".", "$", "@")):
cli = alias.get(R.norm(e.name))
carpetas.append({"nombre": e.name, "cliente": cli})
except OSError:
pass
cfg = CONFIG["discos"].get(d, {})
res.append({"letra": d, "unc": unc.get(d, ""), "nombre": cfg.get("nombre", ""), "nivel": cfg.get("nivel", "" if unc.get(d) else "local"),
"total": u.total, "libre": u.free, "carpetas": sorted(carpetas, key=lambda c: c["nombre"].lower())})
# dónde vive cada cliente
por_cliente = defaultdict(list)
for disco in res:
for c in disco["carpetas"]:
if c["cliente"]:
por_cliente[c["cliente"]].append(f"{disco['letra']}\\{c['nombre']}")
for r in CONFIG["raices"]:
por_cliente[r["cliente"]].append(r["ruta"])
datos = {"discos": res, "clientes": {k: sorted(set(v)) for k, v in por_cliente.items()}}
_DISCOS_CACHE.update(ts=time.time(), datos=datos)
return datos
# ==========================================================================
# Optimización: análisis (ffprobe), candidatos, cola, motores
# ==========================================================================
ANALISIS = {} # raiz -> progreso
def ffprobe(ruta):
cmd = [ruta_herramienta("ffprobe"), "-v", "error", "-show_entries",
"stream=codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,bit_rate,nb_frames",
"-show_entries", "format=duration,bit_rate", "-of", "json", ruta]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA).stdout
j = json.loads(out or "{}")
except Exception: # noqa
return None
v = next((s for s in j.get("streams", []) if s.get("codec_type") == "video"), None)
a = next((s for s in j.get("streams", []) if s.get("codec_type") == "audio"), None)
fmt = j.get("format", {})
if not v:
return None
fr = v.get("r_frame_rate", "0/1")
try:
num, den = fr.split("/")
fps = float(num) / float(den) if float(den) else 0
except ValueError:
fps = 0
dur = float(fmt.get("duration") or 0)
kbps = int(int(fmt.get("bit_rate") or v.get("bit_rate") or 0) / 1000)
pix = v.get("pix_fmt") or ""
return {
"codec": v.get("codec_name") or "", "perfil": v.get("profile") or "", "pix_fmt": pix,
"ancho": int(v.get("width") or 0), "alto": int(v.get("height") or 0), "fps": round(fps, 3), "dur": dur,
"kbps": kbps, "alfa": 1 if ("a" in pix and pix not in ("yuva", "") and ("rgba" in pix or "argb" in pix or "bgra" in pix or "yuva" in pix)) else 0,
"acodec": (a or {}).get("codec_name") or "",
"pistas_audio": sum(1 for x in j.get("streams", []) if x.get("codec_type") == "audio"),
}
HILOS_ANALISIS = 8 # ffprobe pasa casi todo el tiempo esperando al disco de red
def analizar(raiz, min_mb, reintentar=False):
"""Lee con ffprobe los clips que faltan (códec, duración, resolución). Va en paralelo porque cada
lectura es sobre todo espera de red: medido, 0,23 s por clip; 15.423 clips en serie pasan de la hora
y con ocho a la vez bajan a unos ocho minutos.
Con `reintentar` también vuelve sobre los que no se pudieron leer (analizado = 2), por si el problema
era un ffprobe viejo o una unidad que estaba desconectada."""
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time(),
"cancelar": False, "ilegibles": 0}
con = db()
estados = "(0, 2)" if reintentar else "(0)"
pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado IN {estados} AND es_proxy = 0 "
"AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock') "
"ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
prog["total"] = len(pend)
try:
import concurrent.futures as cf
lote = []
def guardar(lote):
with con:
for ruta, info in lote:
if info:
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, "
"kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
(info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"],
info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"],
info.get("pistas_audio", 1), ruta))
else:
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (ruta,))
def leer(f):
info = ffprobe(f["ruta"])
if info and f["ext"] == ".braw":
info["codec"] = "braw" # ffprobe lee el BRAW pero no le pone nombre al códec
return f["ruta"], info
with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool:
for ruta, info in pool.map(leer, pend):
if prog["cancelar"]:
break
if info is None:
prog["ilegibles"] += 1
lote.append((ruta, info))
prog["hechos"] += 1
if len(lote) >= 50: # se guarda por tandas: una transacción por clip es mucho más lento
guardar(lote)
lote = []
guardar(lote)
prog["estado"] = "cancelado" if prog["cancelar"] else "listo"
if prog["ilegibles"]:
prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)"
except Exception as ex: # noqa
prog["estado"] = "error"
prog["error"] = str(ex)
finally:
con.close()
def kbps_objetivo(ancho, alto, fps, perfil):
bpp = PERFILES.get(perfil, PERFILES["equilibrado"])["bpp"]
fps = fps or 30
return max(1500, int(ancho * alto * fps * bpp / 1000))
def estimar_bytes(f, perfil, formato):
"""peso aproximado de la salida para un archivo del inventario"""
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
dur = f["dur"] or 0
if fm.get("mbps_1080p30"):
pix = (f["ancho"] or 1920) * (f["alto"] or 1080) * (f["fps"] or 30)
return int(fm["mbps_1080p30"] * 1e6 / 8 * dur * pix / (1920 * 1080 * 30))
obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)
return int(obj * 1000 / 8 * dur) + int(256000 / 8 * dur)
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
con = db()
tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
"AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
# solo bloquea el clip lo que está a medias EN ESTE MISMO formato: un clip ya pasado a ProRes debe
# poder convertirse también a H.265, y uno que falló debe poder reintentarse.
en_cola = {r["ruta"]: r["estado"] for r in con.execute(
"SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')",
(formato,)).fetchall()}
con.close()
out = []
for f in filas:
motivo_no = ""
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
if f["es_stock"]:
motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)"
elif f["alfa"]:
motivo_no = "tiene canal alfa (HEVC lo perdería)"
elif fm["codec"] == "hevc" and f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * fm.get("bpp_mult", 1.0) * 1.3:
motivo_no = "ya está en HEVC/AV1 eficiente"
elif motor == "resolve" and formato != "h265_10bit":
motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits"
elif tope and not es_viejo(f, tope):
motivo_no = f"tiene menos de {meses} meses"
elif f["contexto"] == "EXPORTADOS" and not incluir_exportados:
motivo_no = "es un exportado / master"
elif not f["ancho"] or not f["dur"]:
motivo_no = "no se pudo leer (ffprobe)"
obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
est = estimar_bytes(f, perfil, formato)
out.append({
"ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"],
"pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"],
"kbps_objetivo": obj, "estimado": est, "motor": motor, "excluido": motivo_no, "fecha": f["fecha"], "rol": f["rol"],
"contexto": f["contexto"], "en_cola": en_cola.get(f["ruta"], ""),
})
return out
def salida_para(ruta, raiz, formato="h265_10bit"):
o = CONFIG["optimizar"]
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
base, _ = os.path.splitext(ruta)
sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
if o.get("salida") == "espejo":
rel = os.path.relpath(base, raiz)
# el sufijo también aquí: sin él, H.265 y H.264 del mismo clip iban al mismo archivo
return os.path.join(raiz, "_Archivo", rel + sufijo + fm["ext"])
return base + sufijo + fm["ext"]
def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
if formato not in FORMATOS:
formato = "h265_10bit"
fm = FORMATOS[formato]
con = db()
ids = []
with con:
for r in rutas:
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone()
if not f:
continue
ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r, formato)).fetchone()
if ya:
continue
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
if motor == "resolve" and formato != "h265_10bit":
continue # los BRAW solo salen por Resolve a H.265 10 bits
salida = salida_para(r, raiz, formato)
# el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola.
otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone()
if otro or os.path.exists(salida):
continue
kb = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz, formato))
ids.append(cur.lastrowid)
con.close()
return ids
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0}
def set_trabajo(id_, **kw):
con = db()
with con:
sets = ", ".join(f"{k} = ?" for k in kw)
con.execute(f"UPDATE trabajos SET {sets} WHERE id = ?", (*kw.values(), id_))
con.close()
def formato_de(t):
"""el formato de un trabajo, tolerando filas viejas sin columna"""
try:
f = t["formato"] if "formato" in t.keys() else None
except AttributeError:
f = t.get("formato")
return f or "h265_10bit"
def correr_ffmpeg(t):
o = CONFIG["optimizar"]
con = db()
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
con.close()
dur = (f["dur"] if f else 0) or 0
fps = (f["fps"] if f else 30) or 30
chroma = t["chroma"] or "420"
cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"]
# el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir»
# salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca)
cq = max(14, min(38, round(cq + FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"]).get("cq_mas", 0))))
maxrate = int(t["kbps"] * 1.8)
salida = t["salida"]
formato = formato_de(t)
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
_m = motores()
motor = _m["formatos"].get(formato)
if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"):
# la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU
motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None
if motor is None:
set_trabajo(t["id"], estado="error", log="esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0")
return
if not motor:
set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)")
return
os.makedirs(os.path.dirname(salida), exist_ok=True)
tmp = salida + ".parcial" + fm["ext"]
gop = str(int(round(fps * 2)))
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
if fm["codec"] == "prores":
# sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no
# salía a la tasa de un HQ (que es justo lo que promete el formato)
cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"]
elif motor == "gpu":
diez = fm["bits"] == 10
cmd += ["-c:v", fm["gpu"],
"-pix_fmt", ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p",
"-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0",
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k",
"-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32",
"-bf", "3", "-b_ref_mode", "middle", "-g", gop]
if fm["codec"] == "hevc":
cmd += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"]
else:
cmd += ["-profile:v", "high"]
else:
diez = fm["bits"] == 10
cmd += ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2),
"-pix_fmt", ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p",
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop]
if fm["codec"] == "hevc":
cmd += ["-tag:v", "hvc1"]
if diez:
cmd += ["-profile:v", "main422-10" if chroma == "422" else "main10"]
cmd += ["-map_metadata", "0"]
if fm["ext"] == ".mp4":
cmd += ["-movflags", "+faststart"]
if fm["codec"] == "prores":
cmd += ["-c:a", "pcm_s16le"]
elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1:
cmd += ["-c:a", "copy"]
else:
# con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4
cmd += ["-c:a", "aac", "-b:a", "320k"]
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}")
return
ultimo = 0
for linea in p.stdout:
if COLA["cancelar"]:
p.kill()
break
if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="):
try:
us = int(linea.split("=")[1])
if linea.startswith("out_time_ms="):
us = us # ffmpeg reporta microsegundos en ambas claves
pct = min(99.0, us / 1e6 / dur * 100) if dur else 0
if time.time() - ultimo > 1:
set_trabajo(t["id"], pct=pct)
ultimo = time.time()
except ValueError:
pass
err = p.stderr.read()
p.wait()
if COLA["cancelar"]:
set_trabajo(t["id"], estado="cancelado", pct=0)
try:
os.remove(tmp)
except OSError:
pass
return
if p.returncode != 0 or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
try:
os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
except OSError:
pass
return
os.replace(tmp, salida)
try:
st = os.stat(t["ruta"])
os.utime(salida, (st.st_atime, st.st_mtime))
except OSError:
pass
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time())
def correr_resolve(trabajos):
"""Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte)."""
o = CONFIG["optimizar"]
con = db()
jobs = []
for t in trabajos:
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
jobs.append({"id": t["id"], "src": t["ruta"], "dst": t["salida"], "w": (f["ancho"] if f else 0) or 0,
"h": (f["alto"] if f else 0) or 0, "fps": (f["fps"] if f else 0) or 0, "kbps": t["kbps"],
"color": t["color"] or "rec709"})
set_trabajo(t["id"], estado="corriendo", pct=0)
con.close()
lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json")
with open(lote, "w", encoding="utf-8") as fh:
json.dump(jobs, fh, ensure_ascii=False)
if CONGELADO and not o.get("python_resolve"):
cmd = [sys.executable, "--puente", lote] # el mismo .exe hace de puente
else:
py = o.get("python_resolve") or sys.executable
cmd = [py, os.path.join(BASE, "resolve_bridge.py"), lote]
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
for t in trabajos:
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el puente de Resolve: {ex}")
return
log_general = []
for linea in p.stdout:
linea = linea.strip()
if not linea:
continue
if linea.startswith("{"):
try:
ev = json.loads(linea)
except ValueError:
log_general.append(linea)
continue
if "id" in ev:
kw = {}
if "pct" in ev:
kw["pct"] = ev["pct"]
if ev.get("estado"):
kw["estado"] = ev["estado"]
if ev.get("log"):
kw["log"] = ev["log"][-3000:]
if ev.get("estado") == "listo":
kw["fin"] = time.time()
try:
kw["bytes_out"] = os.path.getsize(ev.get("salida") or "")
except OSError:
pass
set_trabajo(ev["id"], **kw)
else:
COLA["mensaje"] = ev.get("msg", "")
log_general.append(ev.get("msg", ""))
else:
log_general.append(linea)
if COLA["cancelar"]:
p.kill()
break
p.wait()
con = db()
for t in trabajos:
r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
if r and r["estado"] == "corriendo":
set_trabajo(t["id"], estado="cancelado" if COLA["cancelar"] else "error",
log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo")
con.close()
def _verificar_listos(ids):
"""al acabar cada conversión se verifica sola (códec, 10 bits, duración): el usuario solo decide si reemplaza"""
try:
con = db()
listos = [r["id"] for r in con.execute(f"SELECT id FROM trabajos WHERE estado = 'listo' AND id IN ({','.join('?' * len(ids))})", ids).fetchall()] if ids else []
con.close()
if listos:
verificar(listos)
except Exception as ex: # noqa
COLA["mensaje"] = f"no se pudo verificar: {ex}"
def bucle_cola():
try:
while COLA["activa"]:
con = db()
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'ffmpeg' ORDER BY id LIMIT 1").fetchone()
lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()]
con.close()
if t:
COLA["actual"] = t["id"]
set_trabajo(t["id"], estado="corriendo", pct=0)
try:
correr_ffmpeg(dict(t))
except Exception as ex: # noqa
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
_verificar_listos([t["id"]])
COLA["hechos"] += 1
elif lote_resolve:
COLA["actual"] = lote_resolve[0]["id"]
try:
correr_resolve(lote_resolve)
except Exception as ex: # noqa
for r in lote_resolve:
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
_verificar_listos([r["id"] for r in lote_resolve])
COLA["hechos"] += len(lote_resolve)
else:
break
COLA["actual"] = None
if COLA["cancelar"]:
COLA["cancelar"] = False
break
except Exception as ex: # noqa
COLA["mensaje"] = f"la cola se detuvo por un error: {ex}"
finally:
# pase lo que pase, la cola no queda «activa» para siempre (bloqueaba el cierre del programa)
COLA["activa"] = False
COLA["actual"] = None
COLA["hilo"] = None
def recuperar_huerfanas():
"""Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de
una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso
Convertir decía «nada» (visto el 2026-09-15: 5 clips de V:\\Galeano al 18-89 % desde el día 10).
Vuelven a «pendiente» con el motivo en el log; Reanudar los convierte desde cero."""
if COLA["hilo"] and COLA["hilo"].is_alive():
return 0
con = db()
with con:
filas = con.execute("SELECT id, pct FROM trabajos WHERE estado = 'corriendo'").fetchall()
for f in filas:
con.execute("UPDATE trabajos SET estado = 'pendiente', pct = 0, log = ? WHERE id = ?",
(f"se interrumpió al cerrar OrganiLAB (iba al {round(f['pct'] or 0)} %); pulsa Reanudar para convertirlo de nuevo", f["id"]))
con.close()
return len(filas)
class ErrorCola(ValueError):
"""Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto."""
def __init__(self, mensaje, datos):
super().__init__(mensaje)
self.datos = datos
_COLA_LOCK = threading.Lock()
def iniciar_cola():
# el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que
# alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal.
with _COLA_LOCK:
if COLA["hilo"] and COLA["hilo"].is_alive():
return False
recuperar_huerfanas()
COLA["activa"] = True
COLA["cancelar"] = False
COLA["hechos"] = 0
COLA["inicio"] = time.time()
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
def verificar(ids):
con = db()
res = []
for id_ in ids:
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
if not t or not os.path.exists(t["salida"]):
res.append({"id": id_, "ok": False, "motivo": "no existe la salida"})
continue
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
info = ffprobe(t["salida"])
if not info:
res.append({"id": id_, "ok": False, "motivo": "ffprobe no pudo leer la salida"})
set_trabajo(id_, estado="error", log="verificación: ffprobe no pudo leer la salida")
continue
dur_src = (f["dur"] if f else 0) or 0
tol = max(0.5, dur_src * 0.01)
problemas = []
if dur_src and abs(info["dur"] - dur_src) > tol:
problemas.append(f"duración {info['dur']:.2f}s vs original {dur_src:.2f}s")
fm = FORMATOS.get((t["formato"] if "formato" in t.keys() else None) or "h265_10bit", FORMATOS["h265_10bit"])
if info["codec"] != fm["codec"]:
problemas.append(f"códec {info['codec']} (se esperaba {fm['codec']})")
if fm["bits"] == 10 and "10" not in info["pix_fmt"]:
problemas.append(f"no es 10 bits ({info['pix_fmt']})")
if os.path.getsize(t["salida"]) < 100000:
problemas.append("archivo demasiado pequeño")
if problemas:
set_trabajo(id_, estado="error", log="verificación: " + "; ".join(problemas))
res.append({"id": id_, "ok": False, "motivo": "; ".join(problemas)})
else:
set_trabajo(id_, estado="verificado", bytes_out=os.path.getsize(t["salida"]))
res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"})
con.close()
return res
def reemplazar(ids):
"""El original se va a _Para borrar/originales (misma raíz). Nunca se borra."""
con = db()
res = []
lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia
movimientos, raiz_lote = 0, ""
for id_ in ids:
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
if not t or t["estado"] != "verificado":
res.append({"id": id_, "ok": False, "motivo": "solo se reemplazan trabajos verificados"})
continue
if ((t["formato"] if "formato" in t.keys() else "") or "").startswith("prores"):
res.append({"id": id_, "ok": False, "motivo": "ProRes es un formato de edición: queda junto al original, no lo reemplaza"})
continue
if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024:
res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"})
set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar")
continue
raiz = t["raiz"]
raiz_lote = raiz_lote or raiz
rel = os.path.relpath(t["ruta"], raiz)
destino = os.path.join(raiz, R.PAPELERA, "originales", rel)
try:
os.makedirs(os.path.dirname(destino), exist_ok=True)
destino = nombre_libre(destino)
os.rename(t["ruta"], destino)
movimientos += 1
bitacora({"lote": lote, "op": "mover", "de": t["ruta"], "a": destino, "trabajo": id_})
# el sidecar .xml de Sony y el .mp4 proxy de Blackmagic se van con el original
base, ext = os.path.splitext(t["ruta"])
for extra in (base + "M01.xml", base + "M01.XML"):
if os.path.exists(extra):
d2 = os.path.join(os.path.dirname(destino), os.path.basename(extra))
os.rename(extra, d2)
movimientos += 1
bitacora({"lote": lote, "op": "mover", "de": extra, "a": d2, "trabajo": id_})
set_trabajo(id_, estado="reemplazado")
with con:
con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = 1 WHERE ruta = ?", (destino, os.path.dirname(destino), t["ruta"]))
res.append({"id": id_, "ok": True, "motivo": f"original en {os.path.relpath(destino, raiz)}"})
except OSError as ex:
res.append({"id": id_, "ok": False, "motivo": str(ex)})
con.close()
if movimientos:
bitacora({"lote": lote, "op": "fin", "raiz": raiz_lote, "filas": len(ids), "movimientos": movimientos,
"errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"]]})
return res
# ==========================================================================
# Stock de Envato en proyectos viejos, papelera y respaldo
# ==========================================================================
def tipo_medio(rol, ext):
e = (ext or "").lower()
if e in R.EXT["video"] or e in R.EXT["video_raw"]:
return "Video"
if e in R.EXT["audio"]:
return "Audio"
if e in R.EXT["imagen"] or e in R.EXT["foto_raw"]:
return "Imagenes"
if e in R.EXT["diseno"]:
return "Diseño"
if e in R.EXT["proyecto"]:
return "Proyectos"
if e in R.EXT["documento"]:
return "Documentos"
if e in R.EXT["fuente"] or e in R.EXT["lut"]:
return "Fuentes"
return "Otros"
def limite_meses(meses):
hoy = datetime.date.today()
m = hoy.year * 12 + hoy.month - 1 - int(meses)
return m // 12, m % 12 + 1 # (año, mes) tope: lo anterior es "viejo"
def es_viejo(f, tope):
"""un archivo es viejo si el mes de su pieza (o su fecha) es anterior al tope"""
if f["anio_ruta"] and f["mes_ruta"]:
return (f["anio_ruta"], f["mes_ruta"]) < tope
return (int(f["fecha"][:4]), int(f["fecha"][5:7])) < tope
def stock_viejo(raiz, meses, tipos):
con = db()
tope = limite_meses(meses)
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0", (raiz,)).fetchall()
con.close()
grupos = {}
for f in filas:
t = tipo_medio(f["rol"], f["ext"])
if t not in tipos and not (t == "Otros" and f["ext"] in (".zip", ".rar") and "Video" in tipos):
continue
if not es_viejo(f, tope):
continue
rel = os.path.relpath(f["dir"], raiz)
g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "meses": Counter()})
g["meses"][f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]] += 1
g["n"] += 1
g["bytes"] += f["bytes"]
g["rutas"].append(f["ruta"])
if len(g["ejemplos"]) < 3:
g["ejemplos"].append(f["nombre"])
for g in grupos.values():
g["mes"] = g.pop("meses").most_common(1)[0][0]
out = sorted(grupos.values(), key=lambda g: -g["bytes"])
return {"grupos": out, "n": sum(g["n"] for g in out), "bytes": sum(g["bytes"] for g in out), "tope": f"{tope[0]}-{tope[1]:02d}"}
def stock_a_papelera(raiz, rutas):
lote = "stock-" + time.strftime("%Y%m%d-%H%M%S")
movidos, errores = [], []
for r in rutas:
try:
if not os.path.exists(r):
continue
if not dentro_de(raiz, r):
errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca")
continue
rel = os.path.relpath(r, raiz)
mover(r, os.path.join(raiz, R.PAPELERA, "stock", rel), lote, movidos)
quitar_vacias(os.path.dirname(r), raiz)
except Exception as ex: # noqa
errores.append(f"{r}: {ex}")
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(rutas), "movimientos": len(movidos), "errores": errores})
return {"lote": lote, "movimientos": len(movidos), "errores": errores}
def papelera(raiz):
"""Qué hay en _Para borrar (y en la _Papelera vieja si existe). Solo lee: borrar es cosa de una persona."""
out = []
for carpeta in R.PAPELERAS:
base = os.path.join(raiz, carpeta)
if not os.path.isdir(base):
continue
for nombre in sorted(os.listdir(base)):
p = os.path.join(base, nombre)
if not os.path.isdir(p):
continue
n, b = 0, 0
for d, _, fs in os.walk(p):
for f in fs:
try:
b += os.path.getsize(os.path.join(d, f))
n += 1
except OSError:
pass
out.append({"carpeta": carpeta, "nombre": nombre, "ruta": p, "n": n, "bytes": b})
return {"subcarpetas": out, "bytes": sum(x["bytes"] for x in out), "carpeta": os.path.join(raiz, R.PAPELERA)}
def abrir_carpeta(ruta):
"""Abre una carpeta en el Explorador (solo dentro de alguna raíz configurada)."""
ruta = os.path.normpath(ruta)
permitido = any(ruta.lower().startswith(os.path.normpath(r["ruta"]).lower()) for r in CONFIG.get("raices", []))
if not permitido or not os.path.isdir(ruta):
return {"ok": False, "motivo": "esa carpeta no está dentro de una raíz"}
os.startfile(ruta)
return {"ok": True}
# ==========================================================================
# Duplicados: se juntan en una subcarpeta «Duplicados» junto al que se queda. Nunca se borran.
# ==========================================================================
_EXT_VIDEO_DUP = {".mp4", ".mov", ".mxf", ".braw", ".avi", ".mkv", ".m4v", ".mts", ".r3d"}
def _fuera_de_dup(ruta):
partes = {R.norm(p) for p in os.path.normpath(ruta).split(os.sep)}
return not (partes & (R.PAPELERAS_NORM | {R.norm(R.DUPLICADOS)}))
def _mejor(rutas):
"""Cuál se queda: el que ya esté en una carpeta de grabación, luego el de ruta más corta."""
def clave(r):
en_grab = 0 if "grabacion" in R.norm(r.replace(os.sep, " ")) else 1
return (en_grab, len(r.split(os.sep)), len(r), r.lower())
return sorted(rutas, key=clave)[0]
def duplicados(raiz, minimo=5_000_000):
"""Grupos de archivos repetidos. Dos clases:
identico = mismo nombre y mismo tamaño en sitios distintos.
version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede
cambiar el color o el peso, así que se deja para revisar a mano."""
con = db()
filas = [dict(r) for r in con.execute(
"SELECT ruta, dir, nombre, ext, bytes FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0",
(os.path.normpath(raiz), minimo)).fetchall()]
con.close()
filas = [f for f in filas if _fuera_de_dup(f["ruta"]) and os.path.exists(f["ruta"])]
grupos, usados = [], set()
por_nombre = {}
for f in filas:
por_nombre.setdefault((f["nombre"].lower(), f["bytes"]), []).append(f)
for (nombre, b), fs in por_nombre.items():
if len(fs) < 2:
continue
rutas = [f["ruta"] for f in fs]
queda = _mejor(rutas)
mueven = [r for r in rutas if r != queda]
usados.update(rutas)
grupos.append({"tipo": "identico", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven,
"bytes": b * len(mueven), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)})
por_raiz = {}
for f in filas:
if f["ruta"] in usados or (f["ext"] or "").lower() not in _EXT_VIDEO_DUP:
continue
stem = os.path.splitext(f["nombre"])[0].lower()
if len(stem) < 6:
continue
por_raiz.setdefault(stem, []).append(f)
for stem, fs in por_raiz.items():
if len({(f["ext"] or "").lower() for f in fs}) < 2:
continue
fs = sorted(fs, key=lambda f: -(f["bytes"] or 0))
queda = fs[0]["ruta"] # el más pesado suele ser el original
mueven = [f["ruta"] for f in fs[1:]]
grupos.append({"tipo": "version", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven,
"bytes": sum(f["bytes"] or 0 for f in fs[1:]), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)})
grupos.sort(key=lambda g: -g["bytes"])
return {"grupos": grupos, "n": sum(len(g["mueven"]) for g in grupos), "bytes": sum(g["bytes"] for g in grupos),
"identicos": sum(1 for g in grupos if g["tipo"] == "identico"), "versiones": sum(1 for g in grupos if g["tipo"] == "version")}
def huella_rapida(ruta, trozo=1 << 20):
"""sha256 del primer MB, uno del centro y el último (y del tamaño): suficiente para distinguir dos archivos que solo
coinciden en nombre y peso, sin leer gigas por la red."""
h = hashlib.sha256()
tam = os.path.getsize(ruta)
h.update(str(tam).encode())
with open(ruta, "rb") as fh:
h.update(fh.read(trozo))
if tam > trozo * 3:
fh.seek(tam // 2)
h.update(fh.read(trozo))
if tam > trozo * 2:
fh.seek(tam - trozo)
h.update(fh.read(trozo))
return h.hexdigest()
def apartar_duplicados(raiz, grupos=None):
"""Mueve cada copia a <carpeta del que se queda>/Duplicados. Reversible por lote; nada se borra.
Las copias «idénticas» se comprueban por contenido antes de moverlas: si difieren, se dejan y se avisa."""
raiz = os.path.normpath(raiz)
if grupos is None:
grupos = duplicados(raiz)["grupos"]
lote = "dup-" + time.strftime("%Y%m%d-%H%M%S")
movidos, errores, distintos = [], [], 0
for g in grupos:
ref = None
if g.get("tipo") == "identico":
try:
ref = huella_rapida(g["queda"])
except OSError as ex:
errores.append(f"{g['queda']}: no se pudo leer para comparar ({ex}); el grupo se deja")
continue
for r in g["mueven"]:
try:
if not os.path.exists(r):
continue
if ref is not None and huella_rapida(r) != ref:
distintos += 1
errores.append(f"{os.path.relpath(r, raiz)}: mismo nombre y peso pero contenido distinto; se deja donde está")
continue
mover(r, os.path.join(g["destino"], os.path.basename(r)), lote, movidos)
quitar_vacias(os.path.dirname(r), raiz)
except Exception as ex: # noqa
errores.append(f"{r}: {ex}")
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(grupos), "movimientos": len(movidos), "errores": errores})
return {"lote": lote, "grupos": len(grupos), "movimientos": len(movidos), "errores": errores, "distintos": distintos}
RESPALDO = {"estado": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "errores": [], "destino": "", "cancelar": False, "inicio": 0, "copiados": 0, "saltados": 0}
def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True, meses=0):
con = db()
rc = raiz_cfg(raiz)
cliente = rc["cliente"] if rc else os.path.basename(raiz.rstrip("\\/")) or "Raiz"
base = os.path.join(destino, re.sub(r'[<>:"/\\|?*]', "_", cliente))
conv = {}
for t in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('verificado','reemplazado')").fetchall():
if os.path.exists(t["salida"]):
conv[os.path.normpath(t["ruta"]).lower()] = t["salida"]
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0", (raiz,)).fetchall()
con.close()
marcas_p = [os.sep + p + os.sep for p in R.PAPELERAS]
marca_a = os.sep + "_Archivo" + os.sep
items, vistos = [], set()
omit_raw, omit_stock_b, omit_stock_n, convertidos = [], 0, 0, 0
tope = limite_meses(meses) if int(meses or 0) > 0 else None # solo lo anterior a este (año, mes)
omit_reciente_n, omit_reciente_b = 0, 0
def agregar(ruta, rol, ext, bytes_, mtime):
k = os.path.normpath(ruta).lower()
if k in vistos:
return
vistos.add(k)
rel_dir = os.path.relpath(os.path.dirname(ruta), raiz)
rel_dir = "" if rel_dir == "." else rel_dir
tipo = tipo_medio(rol, ext)
items.append({"origen": ruta, "rel_dir": rel_dir, "nombre": os.path.basename(ruta), "bytes": bytes_, "tipo": tipo, "mtime": mtime, "rel": os.path.relpath(ruta, raiz)})
for f in filas:
if any(m in f["ruta"] for m in marcas_p) or marca_a in f["ruta"]:
continue
if sin_stock and f["es_stock"]:
omit_stock_b += f["bytes"]
omit_stock_n += 1
continue
if tope and not es_viejo(f, tope):
omit_reciente_n += 1
omit_reciente_b += f["bytes"]
continue
k = os.path.normpath(f["ruta"]).lower()
if k in conv:
sal = conv[k]
try:
st = os.stat(sal)
except OSError:
continue
convertidos += 1
vistos.add(k)
agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime)
continue
if f["rol"] == "camara_raw" and not incluir_raw:
omit_raw.append({"rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"]})
continue
agregar(f["ruta"], f["rol"], f["ext"], f["bytes"], f["mtime"])
for k, sal in conv.items():
if os.path.normpath(sal).lower().startswith(os.path.normpath(raiz).lower() + os.sep) and not any(m in sal for m in marcas_p):
try:
st = os.stat(sal)
agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime)
except OSError:
pass
# la subcarpeta por tipo solo se añade donde una carpeta mezcla tipos de medio
# ("Audio/" ya es solo audio: no hace falta "Audio/Audio/")
tipos_por_dir = defaultdict(set)
for it in items:
tipos_por_dir[it["rel_dir"]].add(it["tipo"])
for it in items:
mezcla = por_tipo and len(tipos_por_dir[it["rel_dir"]]) > 1
partes = [base, it["rel_dir"], it["tipo"] if mezcla else "", it["nombre"]]
it["destino"] = os.path.normpath(os.path.join(*[p for p in partes if p]))
por_tipo_res = defaultdict(lambda: {"n": 0, "bytes": 0})
for it in items:
por_tipo_res[it["tipo"]]["n"] += 1
por_tipo_res[it["tipo"]]["bytes"] += it["bytes"]
omit_raw.sort(key=lambda x: -x["bytes"])
return {
"cliente": cliente, "base": base, "destino_base": destino, "n": len(items), "bytes": sum(i["bytes"] for i in items),
"por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])],
"convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]},
"stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
"reciente_omitido": {"n": omit_reciente_n, "bytes": omit_reciente_b, "meses": int(meses or 0)},
"muestra": [{"de": i["rel"], "a": os.path.relpath(i["destino"], destino)} for i in items[:25]],
"items": items,
}
def copiar_respaldo(raiz, plan):
RESPALDO.update(estado="corriendo", hechos=0, total=plan["n"], bytes=0, bytes_total=plan["bytes"], actual="", errores=[],
destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0)
indice = []
# un respaldo por lotes (solo lo de más de 6 meses, luego el resto) reescribía el índice con lo de la
# última tanda y dejaba fuera todo lo anterior: se arranca de lo que ya había.
previo = {}
try:
import csv as _csv
with open(os.path.join(plan["base"], "INDICE.csv"), newline="", encoding="utf-8-sig") as fh:
filas = list(_csv.reader(fh, delimiter=";"))
for fila in filas[1:]:
if len(fila) >= 5:
previo[fila[0]] = tuple(fila)
except (OSError, ValueError):
pass
for it in plan["items"]:
if RESPALDO["cancelar"]:
RESPALDO["estado"] = "cancelado"
break
d = it["destino"]
RESPALDO["actual"] = it["rel"]
try:
os.makedirs(os.path.dirname(d), exist_ok=True)
ya = False
if os.path.exists(d):
st = os.stat(d)
# contra el archivo de ORIGEN tal como está ahora, no contra lo que decía el último escaneo:
# si se editó después de escanear, la copia vieja «coincidía» y no se volvía a copiar nunca
try:
so = os.stat(it["origen"])
tam_o, mt_o = so.st_size, so.st_mtime
except OSError:
tam_o, mt_o = it["bytes"], it["mtime"]
ya = st.st_size == tam_o and abs(st.st_mtime - mt_o) < 2
if ya:
RESPALDO["saltados"] += 1
else:
tmp = d + ".parcial"
shutil.copy2(it["origen"], tmp)
if os.path.getsize(tmp) != os.path.getsize(it["origen"]):
raise OSError("tamaño distinto tras copiar")
os.replace(tmp, d)
RESPALDO["copiados"] += 1
indice.append((os.path.relpath(d, plan["base"]), it["rel"], it["tipo"], it["bytes"], datetime.datetime.fromtimestamp(it["mtime"]).strftime("%Y-%m-%d")))
except Exception as ex: # noqa
RESPALDO["errores"].append(f"{it['rel']}: {ex}")
RESPALDO["hechos"] += 1
RESPALDO["bytes"] += it["bytes"]
try:
os.makedirs(plan["base"], exist_ok=True)
import csv
# lo de esta tanda manda sobre lo que ya había; lo anterior se conserva
for fila in indice:
previo[fila[0]] = fila
todo = sorted(previo.values(), key=lambda x: str(x[0]).lower())
with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh:
w = csv.writer(fh, delimiter=";")
w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"])
w.writerows(todo)
resumen = defaultdict(lambda: defaultdict(lambda: [0, 0]))
for rel_d, rel_o, tipo, b, _ in todo:
carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else ""
resumen[carpeta][tipo][0] += 1
resumen[carpeta][tipo][1] += int(b or 0)
with open(os.path.join(plan["base"], "_MAPA.md"), "w", encoding="utf-8") as fh:
fh.write(f"# Respaldo de {plan['cliente']}\n\nOrigen: `{raiz}` \nFecha: {time.strftime('%Y-%m-%d %H:%M')} \n"
f"Archivos: {len(indice)} · {sum(i[3] for i in indice)/1e9:.1f} GB\n\n")
fh.write("Los clips de cámara están en MP4 H.265 10 bits (Main10). Los BRAW/originales pesados no se copian salvo que se pida.\n\n")
fh.write("| Carpeta | Tipo | Archivos | GB |\n|---|---|---:|---:|\n")
for carpeta in sorted(resumen):
for tipo, (n, b) in sorted(resumen[carpeta].items()):
fh.write(f"| {carpeta or '(raíz)'} | {tipo} | {n} | {b/1e9:.2f} |\n")
with open(os.path.join(plan["base"], "manifest.json"), "w", encoding="utf-8") as fh:
json.dump({"cliente": plan["cliente"], "origen": raiz, "fecha": time.time(), "archivos": len(indice), "bytes": sum(i[3] for i in indice),
"errores": RESPALDO["errores"]}, fh, ensure_ascii=False, indent=1)
except Exception as ex: # noqa
RESPALDO["errores"].append(f"índice: {ex}")
if RESPALDO["estado"] == "corriendo":
RESPALDO["estado"] = "listo"
bitacora({"lote": "respaldo", "op": "respaldo", "raiz": raiz, "a": plan["base"], "archivos": RESPALDO["hechos"], "copiados": RESPALDO["copiados"],
"saltados": RESPALDO["saltados"], "bytes": RESPALDO["bytes"], "errores": len(RESPALDO["errores"])})
# ==========================================================================
# Servidor HTTP
# ==========================================================================
PLANES = {} # raiz -> plan
PLANES_RESPALDO = {}
def _clave(ruta):
return os.path.normpath(ruta)
_ELIGIENDO = threading.Lock()
def elegir_carpeta():
"""Abre el selector de carpetas de Windows (en un PowerShell aparte, para no bloquear el servidor) y devuelve la ruta o None."""
if not _ELIGIENDO.acquire(blocking=False):
raise ValueError("ya hay un selector de carpetas abierto")
try:
ps = ("Add-Type -AssemblyName System.Windows.Forms; "
"$d = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; "
"$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true; "
"if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }")
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", ps], capture_output=True, timeout=600, creationflags=flags)
ruta = out.stdout.decode("utf-8", "replace").strip() or out.stdout.decode("mbcs", "replace").strip()
return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None
finally:
_ELIGIENDO.release()
def _anidadas(raices):
"""(externa, interna) si alguna carpeta de la lista está dentro de otra. El inventario va por raíz y
`archivos.ruta` es única: al escanear la externa, las filas de la interna cambian de dueño y la interna
se queda vacía sin decir nada (su respaldo copiaría cero archivos y terminaría «bien»)."""
rutas = [os.path.normpath(r["ruta"]) for r in raices if r.get("ruta")]
for a in rutas:
for b in rutas:
if a is not b and a.lower() != b.lower() and dentro_de(a, b):
return a, b
return None
def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False):
"""Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear).
Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están
convirtiendo ahora mismo nunca se descartan desde aquí: hay que detener la cola primero."""
k = _clave(ruta)
r = raiz_cfg(ruta)
if not r:
raise ValueError("esa carpeta no está registrada")
if (PROGRESO.get(k) or {}).get("estado") == "corriendo" or (ANALISIS.get(k) or {}).get("estado") == "corriendo":
raise ValueError("la carpeta se está escaneando o analizando; espera a que termine")
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"] and (APLICANDO.get("raiz") or "").lower() == k.lower():
raise ValueError("se está aplicando el plan de esa carpeta; espera a que termine")
recuperar_huerfanas()
con = db()
activos = [dict(x) for x in con.execute("SELECT id, ruta, estado, pct FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo') ORDER BY id", (k.lower(),)).fetchall()]
if activos:
en_curso = [x for x in activos if x["estado"] == "corriendo"]
if en_curso or not descartar_cola:
con.close()
n = len(activos)
por_estado = {"corriendo": len(en_curso), "pendiente": n - len(en_curso)}
que = ", ".join(f"{v} {'convirtiéndose ahora' if e == 'corriendo' else 'en espera'}" for e, v in por_estado.items() if v)
# El texto dice QUÉ hay y QUÉ hacer; los datos (archivos, estados) van aparte para que la
# interfaz los enseñe y ofrezca descartar. Antes solo decía «detén la cola o límpiala».
if en_curso:
msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): detén la cola en Convertir a 10 bits y vuelve a intentarlo"
else:
msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): termínalas con Reanudar en Convertir a 10 bits, o descártalas al desconectar"
raise ErrorCola(msg, {"cola": {"n": n, "por_estado": por_estado, "en_curso": bool(en_curso),
"archivos": [os.path.basename(x["ruta"]) for x in activos[:6]]}})
with con:
con.execute("DELETE FROM trabajos WHERE lower(raiz) = ? AND estado = 'pendiente'", (k.lower(),))
bitacora({"op": "cola_descartada", "raiz": k, "n": len(activos), "archivos": [x["ruta"] for x in activos]})
CONFIG["raices"] = [x for x in CONFIG["raices"] if _clave(x["ruta"]).lower() != k.lower()]
guardar_json(CONFIG_PATH, CONFIG)
PLANES.pop(k, None); PROGRESO.pop(k, None); ANALISIS.pop(k, None)
if borrar_datos:
for t in ("archivos", "carpetas", "escaneos"):
con.execute(f"DELETE FROM {t} WHERE lower(raiz) = ?", (k.lower(),))
con.commit()
con.close()
bitacora({"op": "raiz_quitada", "raiz": k, "datos_borrados": bool(borrar_datos)})
return {"ok": True, "raices": len(CONFIG["raices"])}
def estado_general():
"""Todo lo que la interfaz sondea cada pocos segundos, en una sola respuesta."""
con = db()
esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()}
cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()}
actual = None
if COLA["actual"]:
t = con.execute("SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone()
if t:
actual = dict(t)
con.close()
# «clip 3 de 12»: los hechos en esta tanda + el que va + los que esperan
total_tanda = COLA["hechos"] + (1 if actual else 0) + cola_n.get("pendiente", 0)
esc_ci = {k.lower(): v for k, v in esc.items()}
raices = []
for r in CONFIG["raices"]:
k = _clave(r["ruta"])
raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)})
return {"config": CONFIG, "raices": raices, "herramientas": herramientas(),
"version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO,
"cola": {"activa": COLA["activa"], "actual": actual, "mensaje": COLA["mensaje"], "n": cola_n,
"hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]},
"aplicando": APLICANDO, "asistente": asistente.ESTADO,
"respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])},
"cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "formatos": {k: {"nombre": v["nombre"], "que": v["que"], "ext": v["ext"]} for k, v in FORMATOS.items()}, "plantillas": PLANTILLAS}
def _plural(n, uno, varios):
return f"{n:,}".replace(",", ".") + " " + (uno if n == 1 else varios)
# Qué enseñar en lugar de cada variable de una plantilla, y cómo se llama en llano.
EJEMPLO_VAR = {
"anio": ("2026", "el año del material"),
"mes": ("09- Septiembre", "el mes del material"),
"dia": ("15- Viernes", "el día en que se grabó, y solo si ese mes hubo más de un rodaje"),
"nombre": ("Nombre de la pieza", "el nombre de la pieza o de la carpeta"),
"fuente": ("URSA_BRAW", "la cámara de la que salió"),
"app": ("Premiere", "el programa que abre el proyecto"),
}
# Estas dos casi siempre están vacías (el archivo cae directo en la carpeta de arriba), así que no son
# un nivel más del árbol: son una nota en el nivel que las contiene.
VAR_NOTA = {
"sub": "y si el material venía dentro de una subcarpeta, se conserva",
"rel": "se copia la ruta tal cual venía, para poder devolverla a su sitio",
}
def mapa_estructura(tipo, raiz=None):
"""El árbol de carpetas que produce una plantilla, con qué cae en cada una y cuánto hay ya.
Es la respuesta a «¿dónde va a acabar mi material?» antes de mover nada. Se arma con las plantillas
de verdad, así que lo que enseña es exactamente lo que la app va a hacer, y si se edita una
plantilla el árbol cambia con ella."""
tipo = tipo if tipo in PLANTILLAS else "contenido"
pl = PLANTILLAS[tipo]
# qué hay hoy en la carpeta, repartido por cubeta según el rol de cada archivo
hay, apps, cliente = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0]), ""
if raiz:
raiz = _clave(raiz)
r = raiz_cfg(raiz)
cliente = r["cliente"] if r else ""
con = db()
for f in con.execute("SELECT rol, ext, COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos "
"WHERE raiz = ? GROUP BY rol, ext", (raiz,)).fetchall():
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
hay[cub][0] += f["n"]
hay[cub][1] += f["b"]
if cub == "PROYECTOS":
a = R.app_proyecto(f["ext"]) or "Sin programa reconocido"
apps[a][0] += f["n"]
apps[a][1] += f["b"]
con.close()
# nombres de archivo de verdad, para poder probar el mapa con lo que hay en el disco
ejemplos = defaultdict(list)
if raiz:
con = db()
for f in con.execute("SELECT rol, nombre, MAX(bytes) b FROM archivos WHERE raiz = ? AND es_basura = 0 "
"AND es_proxy = 0 GROUP BY rol, ext ORDER BY b DESC", (raiz,)).fetchall():
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
if len(ejemplos[cub]) < 4:
ejemplos[cub].append(f["nombre"])
con.close()
# cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta
roles = defaultdict(list)
for rol, cub in R.ROL_A_CUBETA.items():
roles[cub].append(R.ROL_QUE.get(rol, rol))
raiz_nodo = {"nombre": os.path.basename(raiz.rstrip(os.sep)) or raiz if raiz else "Carpeta del cliente",
"hijos": [], "cubetas": [], "variable": "", "nota": "", "crudo": [], "color": "",
"n": 0, "bytes": 0}
def bajar(nodo, nombre, variable, crudo):
for h in nodo["hijos"]:
if h["nombre"].lower() == nombre.lower():
return h
h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "",
"crudo": list(crudo), "color": "", "n": 0, "bytes": 0}
nodo["hijos"].append(h)
return h
cubetas = []
for cub, titulo in R.CUBETAS.items():
plantilla = pl.get(cub, "")
n, b = hay.get(cub, [0, 0])
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
"asignable": cub not in ("CONTENEDOR", "IGNORAR"),
"color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
"ejemplos": ejemplos.get(cub, []),
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
"guarda": sorted(set(roles.get(cub, []))), "n": n, "bytes": b,
"apps": (sorted(({"nombre": k, "n": v[0], "bytes": v[1]} for k, v in apps.items()),
key=lambda x: -x["bytes"]) if cub == "PROYECTOS" else [])})
if not plantilla:
continue
nodo, crudo = raiz_nodo, []
for seg in plantilla.split("/"):
if seg == "{app}":
crudo.append(seg)
nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1], crudo)
nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia "
"carpeta, se queda con el nombre de esa carpeta")
nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?»
continue
nota = next((v for k, v in VAR_NOTA.items() if seg == "{" + k + "}"), "")
if nota:
nodo["nota"] = nota # no baja un nivel: se queda en la carpeta de arriba
continue
texto, hint = seg, ""
for k, (ej, que) in EJEMPLO_VAR.items():
if "{" + k + "}" in texto:
texto = texto.replace("{" + k + "}", ej)
hint = que if not hint else hint + " y " + que
crudo.append(seg)
nodo = bajar(nodo, texto, hint, crudo)
nodo["cubetas"].append(cub)
nodo["n"] += n
nodo["bytes"] += b
def ordenar(nodo):
# una carpeta con nombre fijo antes que una variable, y dentro de cada grupo por nombre
nodo["hijos"].sort(key=lambda h: (bool(h["variable"]), h["nombre"].lower()))
cols = {R.COLOR_CUBETA.get(c, "") for c in nodo["cubetas"]}
for h in nodo["hijos"]:
ordenar(h)
nodo["n"] += h["n"]
nodo["bytes"] += h["bytes"]
cols.add(h["color"])
cols.discard("")
# una carpeta toma el color de lo que guarda; si mezcla tipos de distinto color, se queda neutra
nodo["color"] = cols.pop() if len(cols) == 1 else ""
ordenar(raiz_nodo)
return {"tipo": tipo, "titulo": R.TIPOS_CLIENTE.get(tipo, tipo), "raiz": raiz or "", "cliente": cliente,
"arbol": raiz_nodo, "cubetas": cubetas, "variables": {k: v[1] for k, v in EJEMPLO_VAR.items()},
"colores": R.COLOR_CUBETA, "pintado": _esta_pintada(raiz) if raiz else False}
def donde_iria(nombre, tipo="contenido"):
"""Qué es un archivo y en qué carpeta lo dejaría la app, solo por su nombre.
Es el mapa al revés: en vez de «esta carpeta guarda esto», «esto va a esta carpeta». Vale para
cualquier nombre, exista o no, porque lo decide el mismo clasificador que usa el escaneo."""
nombre = (nombre or "").strip().strip('"').replace("/", os.sep).split(os.sep)[-1]
if not nombre:
return {"error": "escribe el nombre de un archivo"}
ext = os.path.splitext(nombre)[1].lower()
firma = R.firma_archivo(nombre)
rol = R.rol_archivo(nombre, ext, firma)
cub = R.ROL_A_CUBETA.get(rol, "REVISAR")
fecha, origen = R.fecha_de_nombre(nombre, time.time())
kw = {"anio": int(fecha[:4]), "mes": int(fecha[5:7])}
if cub == "GRABACION":
kw["sub"] = R.FUENTE_CARPETA.get(firma, "OTROS")
if cub == "CELULAR" and tipo == "contenido":
cub, kw["sub"] = "GRABACION", "CELULAR"
if cub == "PROYECTOS":
kw["app"] = R.app_proyecto(ext) or ("Diseño" if rol == "diseno" else "Edicion")
destino = "_Revisar" if cub == "REVISAR" else destino_plantilla(tipo, cub, **kw)
# el día solo se sabe con la carpeta del cliente delante (depende de cuántos rodajes tuvo ese mes),
# así que aquí se dice cuál sería, no que vaya a estar
dia = R.nombre_dia(fecha) if cub in ("GRABACION", "CELULAR") else ""
return {"nombre": nombre, "dia": dia, "rol": rol, "que": R.ROL_QUE.get(rol, rol), "cubeta": cub,
"titulo": R.CUBETAS.get(cub, cub), "color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
"destino": destino, "crudo": [x for x in (PLANTILLAS.get(tipo, {}).get(cub, "") or "").split("/") if x],
"fecha": fecha, "por_fecha": origen, "camara": firma or "",
"app": R.app_proyecto(ext) or ""}
def renombrar_nodo(tipo, ruta, nombre, raiz=None, en_disco=False):
"""Le cambia el nombre a una carpeta del mapa: en las plantillas y, si se pide, en el disco.
`ruta` son los segmentos CRUDOS del mapa («{anio}», «{mes}», «00_Grabacion»), así que el cambio
alcanza a todas las plantillas que pasan por esa carpeta, no solo a una."""
tipo = tipo if tipo in PLANTILLAS else "contenido"
nombre = (nombre or "").strip().strip(". ")
if not nombre:
return {"error": "el nombre no puede quedar vacío"}
if any(c in nombre for c in '\\/:*?"<>|'):
return {"error": 'un nombre de carpeta no puede llevar \\ / : * ? " < > |'}
if "{" in nombre or "}" in nombre:
return {"error": "aquí va un nombre, no una variable: las variables se editan en la plantilla"}
if not ruta or "{" in ruta[-1]:
return {"error": "esa carpeta la nombra el material (el año, el mes, la pieza): no se puede cambiar a mano"}
viejo = ruta[-1]
if R.norm(viejo) == R.norm(nombre):
return {"error": "ya se llama así"}
cambiadas = []
for cub, pl in list(PLANTILLAS[tipo].items()):
segs = pl.split("/")
if len(segs) >= len(ruta) and [x.lower() for x in segs[:len(ruta)]] == [x.lower() for x in ruta]:
segs[len(ruta) - 1] = nombre
PLANTILLAS[tipo][cub] = "/".join(segs)
cambiadas.append(cub)
if not cambiadas:
return {"error": "esa carpeta ya no está en ninguna plantilla; vuelve a abrir el mapa"}
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
PLANES.clear()
movidas, errores = 0, []
if en_disco and raiz:
movidas, errores = _renombrar_en_disco(_clave(raiz), ruta, nombre)
return {"ok": True, "cubetas": cambiadas, "viejo": viejo, "nuevo": nombre,
"movidas": movidas, "errores": errores}
def _casan(segmentos, patron):
"""True si los segmentos de una carpeta real encajan con un patron de plantilla.
«{anio}» y «{mes}» solo valen por algo que de verdad parezca un ano o un mes: sin eso, «{anio}»
encajaba con cualquier carpeta del primer nivel («Efectos» salia como si fuera un ano)."""
if len(segmentos) != len(patron):
return False
for g, p in zip(segmentos, patron):
if p == "{anio}":
if not R.detectar_anio(g):
return False
elif p == "{mes}":
if not R.detectar_mes(g)[0]:
return False
elif p.startswith("{"):
continue
elif R.norm(p) != R.norm(g):
return False
return True
def carpetas_como(raiz, ruta):
"""Las carpetas que hay HOY en el disco en el sitio que describe `ruta` (segmentos crudos)."""
raiz = _clave(raiz)
con = db()
filas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con.close()
out = []
for c in filas:
rel = os.path.relpath(c, raiz)
if rel == "." or not os.path.isdir(c):
continue
if _casan(rel.split(os.sep), ruta):
out.append(c)
return sorted(out, key=lambda x: x.lower())
def _renombrar_en_disco(raiz, ruta, nombre):
"""Renombra en el disco todas las carpetas que estaban en ese sitio del mapa. Queda en la bitácora
como un movimiento normal, así que se deshace desde Historial."""
lote = "renombrar-" + time.strftime("%Y%m%d-%H%M%S")
movidos, errores = [], []
for c in carpetas_como(raiz, ruta):
destino = os.path.join(os.path.dirname(c), nombre)
if os.path.normcase(c) == os.path.normcase(destino):
continue
try:
mover(c, destino, lote, movidos)
except Exception as ex: # noqa
errores.append(f"{os.path.relpath(c, raiz)}: {ex}")
if movidos:
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos),
"movimientos": len(movidos), "errores": errores})
return len(movidos), errores
# ==========================================================================
# Color de carpeta en el Explorador de Windows
# ==========================================================================
#
# Windows no tiene «color de carpeta», pero sí icono por carpeta: un desktop.ini que apunta a un .ico
# y la carpeta marcada como solo lectura para que el Explorador lo lea. La app genera un icono por
# color dentro de la propia carpeta del cliente y lo referencia con ruta relativa, así que los colores
# viajan con el disco y se ven igual desde cualquier PC que lo tenga montado.
F_READONLY, F_HIDDEN, F_SYSTEM = 0x1, 0x2, 0x4
FCSM_ICONFILE, FCS_READ, FCS_FORCEWRITE = 0x10, 0x1, 0x2
SHCNE_UPDATEDIR, SHCNF_PATHW = 0x1000, 0x0005
class _FCS(ctypes.Structure):
"""SHFOLDERCUSTOMSETTINGS: lo que hay en el desktop.ini de una carpeta, visto por el Explorador."""
_fields_ = [("dwSize", ctypes.c_uint32), ("dwMask", ctypes.c_uint32),
("pvid", ctypes.c_void_p), ("pszWebViewTemplate", ctypes.c_wchar_p),
("cchWebViewTemplate", ctypes.c_uint32), ("pszWebViewTemplateVersion", ctypes.c_wchar_p),
("pszInfoTip", ctypes.c_wchar_p), ("cchInfoTip", ctypes.c_uint32),
("pclsid", ctypes.c_void_p), ("dwFlags", ctypes.c_uint32),
("pszIconFile", ctypes.c_wchar_p), ("cchIconFile", ctypes.c_uint32),
("iIconIndex", ctypes.c_int), ("pszLogo", ctypes.c_wchar_p), ("cchLogo", ctypes.c_uint32)]
def _fcs():
f = ctypes.windll.shell32.SHGetSetFolderCustomSettings
f.argtypes = [ctypes.POINTER(_FCS), ctypes.c_wchar_p, ctypes.c_uint32]
f.restype = ctypes.c_long
return f
def _poner_icono(carpeta, icono):
"""Le pone a la carpeta ese .ico (la ruta puede ser relativa, y así los colores viajan con el disco)."""
w = _FCS()
w.dwSize = ctypes.sizeof(_FCS)
w.dwMask = FCSM_ICONFILE
w.pszIconFile = icono
w.iIconIndex = 0
try:
if _fcs()(ctypes.byref(w), carpeta, FCS_FORCEWRITE) != 0:
return False
ctypes.windll.shell32.SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, ctypes.c_wchar_p(carpeta), None)
return True
except Exception: # noqa
return False
def _icono_de(carpeta):
"""El .ico que tiene puesto la carpeta, o "" si no tiene ninguno."""
buf = ctypes.create_unicode_buffer(600)
r = _FCS()
r.dwSize = ctypes.sizeof(_FCS)
r.dwMask = FCSM_ICONFILE
r.pszIconFile = ctypes.cast(buf, ctypes.c_wchar_p)
r.cchIconFile = 600
try:
return buf.value if _fcs()(ctypes.byref(r), carpeta, FCS_READ) == 0 else ""
except Exception: # noqa
return ""
def _ini_nuestro(ruta):
"""Un desktop.ini es de la app si apunta a un icono de su propia carpeta."""
try:
with open(ruta, "rb") as fh:
return R.CARPETA_APP.encode() in fh.read(1024).lower()
except OSError:
return False
def _atributos(ruta, poner=0, quitar=0):
"""Pone o quita atributos y devuelve si de verdad quedaron puestos.
En un NAS por SMB, SetFileAttributesW dice que sí y el atributo no queda: medido el 16-sep-2026,
Z: acepta SYSTEM pero no READONLY, y W: no acepta ninguno de los dos. Por eso se relee."""
try:
a = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
if a == -1:
return False
ctypes.windll.kernel32.SetFileAttributesW(str(ruta), (a | poner) & ~quitar)
if not poner:
return True
b = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
return b != -1 and bool(b & poner)
except Exception: # noqa
return False
def _marcar_carpeta(carpeta):
"""El Explorador solo lee desktop.ini si la carpeta está marcada como solo lectura O como sistema.
Vale cualquiera de las dos: se intentan las dos y basta con que una quede."""
return (_atributos(carpeta, poner=F_READONLY) or _atributos(carpeta, poner=F_SYSTEM))
def _png_carpeta(n, rgb):
"""Una carpeta del color pedido, n×n RGBA, con la pestaña un punto más oscura."""
u = n / 32.0
base = rgb
tab = tuple(int(c * 0.78) for c in rgb)
def rect(px, py, x0, y0, x1, y1, r):
if not (x0 <= px <= x1 and y0 <= py <= y1):
return False
cx = min(max(px, x0 + r), x1 - r)
cy = min(max(py, y0 + r), y1 - r)
return (px - cx) ** 2 + (py - cy) ** 2 <= r * r
filas = []
for y in range(n):
fila = bytearray()
for x in range(n):
dentro_b = dentro_t = 0
for sx, sy in ((.25, .25), (.75, .25), (.25, .75), (.75, .75)):
px, py = (x + sx) / u, (y + sy) / u
if rect(px, py, 2, 8.5, 30, 27, 2.2):
dentro_b += 1
elif rect(px, py, 2, 5, 14.5, 11, 1.4):
dentro_t += 1
if dentro_b:
a, col = dentro_b / 4, base
elif dentro_t:
a, col = dentro_t / 4, tab
else:
fila += bytes((0, 0, 0, 0))
continue
fila += bytes((col[0], col[1], col[2], int(255 * a)))
filas.append(bytes(fila))
raw = b"".join(b"\x00" + f for f in filas)
def chunk(t, d):
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
def _ico_carpeta(hexcol):
rgb = tuple(int(hexcol.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
tam = (256, 48, 32, 16)
imgs = [_png_carpeta(t, rgb) for t in tam]
cab = struct.pack("<HHH", 0, 1, len(tam))
off = 6 + 16 * len(tam)
entradas = b""
for t, im in zip(tam, imgs):
entradas += struct.pack("<BBBBHHII", t % 256, t % 256, 0, 0, 1, 32, len(im), off)
off += len(im)
return cab + entradas + b"".join(imgs)
def _dir_iconos(raiz):
return os.path.join(raiz, R.CARPETA_APP, "iconos")
def _esta_pintada(raiz):
return os.path.isdir(_dir_iconos(raiz))
def _nodos_pintables(nodo, out=None):
"""(segmentos crudos, color) de cada carpeta del mapa que tiene color, las más concretas primero:
«{anio}/{mes}/{nombre}» también encaja con «00_Grabacion», y ahí manda la que tiene nombre fijo."""
out = [] if out is None else out
if nodo.get("crudo") and nodo.get("color"):
out.append((nodo["crudo"], nodo["color"]))
for h in nodo.get("hijos", []):
_nodos_pintables(h, out)
out.sort(key=lambda x: -sum(0 if seg.startswith("{") else 1 for seg in x[0]))
return out
def _carpetas_a_pintar(raiz):
"""Cada carpeta real del cliente con el color de lo que guarda."""
mapa = mapa_estructura((raiz_cfg(raiz) or {}).get("tipo", "contenido"), raiz)
patrones = _nodos_pintables(mapa["arbol"])
con = db()
reales = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con.close()
out = {}
for c in reales:
rel = os.path.relpath(c, raiz)
if rel == "." or rel.split(os.sep)[0].lower() == R.CARPETA_APP:
continue
segs = rel.split(os.sep)
for crudo, color in patrones:
if _casan(segs, crudo):
out[c] = color
break
# lo que cuelga de una carpeta con color es más de lo mismo («URSA_BRAW» dentro de «00_Grabacion»):
# hereda su color, y así en el Explorador una rama entera se ve de un color
for c in sorted(reales, key=lambda x: len(x)):
if c in out or os.path.relpath(c, raiz) == ".":
continue
padre = os.path.dirname(c)
while padre.lower().startswith(raiz.lower()) and padre.lower() != raiz.lower():
if padre in out:
out[c] = out[padre]
break
padre = os.path.dirname(padre)
return out
def pintar_carpetas(raiz):
"""Le pone a cada carpeta el icono del color de lo que guarda."""
raiz = _clave(raiz)
if not os.path.isdir(raiz):
return {"error": "la carpeta no está conectada"}
objetivo = _carpetas_a_pintar(raiz)
if not objetivo:
return {"error": "no hay ninguna carpeta reconocida todavía: escanea y ordena primero"}
iconos = _dir_iconos(raiz)
try:
os.makedirs(iconos, exist_ok=True)
_atributos(os.path.join(raiz, R.CARPETA_APP), poner=F_HIDDEN)
except OSError as ex:
return {"error": f"no se pudo crear la carpeta de iconos: {ex}"}
for color in set(objetivo.values()):
ico = os.path.join(iconos, color.lstrip("#").lower() + ".ico")
if not os.path.exists(ico):
with open(ico, "wb") as fh:
fh.write(_ico_carpeta(color))
pintadas, sin_marca, errores = 0, 0, []
for carpeta, color in sorted(objetivo.items()):
try:
rel = os.path.join(os.path.relpath(iconos, carpeta), color.lstrip("#").lower() + ".ico")
_atributos(os.path.join(carpeta, "desktop.ini"), quitar=F_READONLY)
if not _poner_icono(carpeta, rel):
sin_marca += 1
continue
# la API marca la carpeta ella sola, pero algunos discos de red no dejan: se comprueba
if _icono_de(carpeta).lower() == rel.lower() and _marcar_carpeta(carpeta):
pintadas += 1
else:
sin_marca += 1
except OSError as ex:
if len(errores) < 5:
errores.append(f"{os.path.relpath(carpeta, raiz)}: {ex}")
if not pintadas and sin_marca:
# el disco no deja marcar las carpetas: el desktop.ini se quedaría ahí sin hacer nada
despintar_carpetas(raiz)
return {"error": "este disco no deja marcar las carpetas, y sin esa marca el Explorador ni mira el "
"color. Pasa en algunos NAS y discos de red. No se ha dejado nada escrito.",
"pintadas": 0, "errores": []}
try:
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None) # SHCNE_ASSOCCHANGED
except Exception: # noqa
pass
bitacora({"lote": "colores-" + time.strftime("%Y%m%d-%H%M%S"), "op": "colores", "raiz": raiz,
"pintadas": pintadas, "errores": errores})
return {"pintadas": pintadas, "sin_marca": sin_marca, "colores": len(set(objetivo.values())),
"errores": errores}
def despintar_carpetas(raiz):
"""Quita los colores: borra los desktop.ini que puso la app y su carpeta de iconos."""
raiz = _clave(raiz)
quitadas, errores = 0, []
for base, dirs, files in os.walk(raiz):
if os.path.basename(base).lower() == R.CARPETA_APP:
dirs[:] = []
continue
for f in files:
if f.lower() != "desktop.ini":
continue
ini = os.path.join(base, f)
if not _ini_nuestro(ini):
continue
try:
_atributos(ini, quitar=F_READONLY | F_HIDDEN | F_SYSTEM)
os.remove(ini)
_atributos(base, quitar=F_READONLY)
quitadas += 1
except OSError as ex:
if len(errores) < 5:
errores.append(f"{os.path.relpath(base, raiz)}: {ex}")
try:
shutil.rmtree(os.path.join(raiz, R.CARPETA_APP), ignore_errors=True)
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None)
except Exception: # noqa
pass
return {"quitadas": quitadas, "errores": errores}
def acciones(raiz):
"""Todo lo que se puede hacer AHORA MISMO en esta carpeta, en orden, cada cosa con su cifra.
Es la vista principal: en lugar de repartir el trabajo entre pestañas dentro de pestañas, una sola
lista dice qué falta y cuánto pesa. Todo sale de consultas baratas sobre el inventario; lo único
que puede costar es el plan de orden, que solo se usa si ya estaba calculado."""
raiz = _clave(raiz)
r = raiz_cfg(raiz)
if not r:
return {"error": "carpeta no registrada"}
con = db()
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
out = []
def añadir(**kw):
kw.setdefault("estado", "toca") # toca | hecho | espera
out.append(kw)
if not esc:
con.close()
añadir(clave="escanear", titulo="Escanear la carpeta", estado="toca", boton="Escanear",
que="Lee nombres, tamaños y fechas de todos los archivos. No mueve nada.",
cifra="", detalle="En una carpeta grande del NAS tarda unos minutos.")
return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": None}
en_papelera = " AND " + " AND ".join(
["ruta NOT LIKE ? ESCAPE '!'"] * len(R.PAPELERAS))
args_pap = [esc_like(os.path.join(raiz, pp)) + "%" for pp in R.PAPELERAS]
# --- 1. basura y proxies que siguen fuera de la papelera ---
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? "
"AND (es_basura = 1 OR es_proxy = 1)" + en_papelera, [raiz] + args_pap).fetchone()
if fila["n"]:
añadir(clave="limpiar", titulo="Apartar basura y proxies", boton="Ver y apartar",
que="Archivos de sistema y proxies, que Resolve y Premiere regeneran solos.",
cifra=f"{_plural(fila['n'], 'archivo', 'archivos')} · {_gb(fila['b'])}",
detalle=f"Van a «{R.PAPELERA}» dentro de la misma carpeta. No se borra nada.",
ir="plan", paso="limpiar")
else:
añadir(clave="limpiar", titulo="Basura y proxies", estado="hecho",
que="No queda nada suelto que apartar.", cifra="", detalle="")
# --- 2. carpetas que la app no reconoce ---
voc = vocabulario(raiz)
sin = [t for t in voc if not t.get("cubeta") and not t.get("dentro_pieza")]
seguras = [t for t in sin if t.get("sug_conf") == "alta"]
if sin:
# sin sumar bytes: una carpeta sin reconocer dentro de otra sin reconocer contaría dos veces
# el mismo material, y salían totales mayores que el propio disco
con_sug = [t for t in sin if t.get("sugerencia")]
añadir(clave="nombrar", titulo="Decir qué son " + _plural(len(sin), "carpeta", "carpetas"), boton="Nombrarlas" if len(sin) > 1 else "Nombrarla",
que="Sin esto la app no las mueve: se quedan donde están.",
cifra=_plural(len(sin), "carpeta", "carpetas") + (f" · {len(seguras)} de un clic" if seguras else ""),
detalle=(f"La app propone qué son {len(con_sug)} de ellas mirando lo que tienen dentro"
+ (f", y {len(seguras)} con bastante seguridad como para aceptarlas todas juntas." if seguras else ".")
if con_sug else "Puedes dejarlas sin nombrar: simplemente no se moverán."),
ir="nombrar")
else:
añadir(clave="nombrar", titulo="Carpetas reconocidas", estado="hecho",
que="La app sabe qué es cada carpeta.", cifra="", detalle="")
# --- 3 y 4. ordenar (usa el plan solo si ya estaba calculado) ---
plan = PLANES.get(raiz)
for clave, titulo, que in (("fechas", "Ordenar por año y mes",
"Meses con nombre completo, grabaciones repartidas por mes y cámara, piezas numeradas."),
("resto", "Ordenar lo demás",
"Exportados, proyectos, música y marca a su carpeta.")):
if plan:
fs = [f for f in plan["filas"] if f.get("paso") == clave]
marc = [f for f in fs if f.get("marcado")]
if fs:
añadir(clave=clave, titulo=titulo, boton="Ver y aplicar", que=que,
cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}",
detalle=(f"{len(marc)} están marcados; el resto son dudosos y no se mueven salvo que los marques."
if len(marc) < len(fs) else "Todo se mueve dentro de la misma carpeta."),
ir="plan", paso=clave)
else:
añadir(clave=clave, titulo=titulo, estado="hecho", que="Nada que mover.", cifra="", detalle="")
else:
# sin el plan calculado no se sabe si queda algo: se ofrece calcularlo, pero no cuenta como
# pendiente (si no, después de ordenarlo todo la lista seguía diciendo que faltaban dos cosas)
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera",
detalle="Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave)
# --- 5. carpetas que quedaron vacías ---
vac = carpetas_vacias(raiz)
if vac["n"]:
dentro = sum(c["subcarpetas"] for c in vac["carpetas"])
añadir(clave="vacias", titulo="Borrar carpetas vacías", boton="Ver y borrar",
que="Carpetas sin un solo archivo dentro: lo que queda después de mover el material.",
cifra=_plural(vac["n"], "carpeta", "carpetas") + (f" · {dentro} dentro" if dentro else ""),
detalle="Es lo único que la app borra, y solo porque no hay nada dentro. Se revisan una a "
"una y se deshace desde Historial.",
ir="vacias")
else:
añadir(clave="vacias", titulo="Carpetas vacías", estado="hecho",
que="No queda ninguna carpeta hueca.", cifra="", detalle="")
# --- 6. stock de Envato viejo ---
tope = limite_meses(3)
viejo = [f for f in con.execute(
"SELECT bytes, fecha, anio_ruta, mes_ruta FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0"
+ en_papelera, [raiz] + args_pap).fetchall() if es_viejo(f, tope)]
if viejo:
añadir(clave="stock", titulo="Apartar stock de Envato viejo", boton="Ver y apartar",
que="De proyectos ya publicados: se vuelve a descargar con la suscripción.",
cifra=f"{_plural(len(viejo), 'archivo', 'archivos')} · {_gb(sum(f['bytes'] for f in viejo))}",
detalle="Son los de proyectos de más de 3 meses. Puedes ajustar el corte.",
ir="stock")
# --- 6. material repetido ---
rep_fila = con.execute(
"SELECT COALESCE(SUM(n - 1), 0) n, COALESCE(SUM((n - 1) * bytes), 0) b FROM "
"(SELECT nombre, bytes, COUNT(*) n FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 "
"AND bytes > 5000000" + en_papelera + " GROUP BY nombre, bytes HAVING COUNT(*) > 1)",
[raiz] + args_pap).fetchone()
if rep_fila["n"]:
añadir(clave="repetidos", titulo="Juntar material repetido", boton="Ver repetidos",
que="Mismo nombre y mismo peso en varias carpetas.",
cifra=f"{_plural(rep_fila['n'], 'copia de más', 'copias de más')} · {_gb(rep_fila['b'])}",
detalle="Se comprueba el contenido antes de tocar nada; la copia buena se queda donde está.",
ir="repetidos")
# --- 7. convertir clips viejos ---
o = CONFIG["optimizar"]
cam = con.execute(
"SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b, SUM(analizado = 1) ana FROM archivos WHERE raiz = ? "
"AND rol IN ('camara_raw','camara_video') AND es_proxy = 0 AND es_basura = 0 AND bytes >= ?" + en_papelera,
[raiz, int(o.get("min_mb", 300)) * 1024 * 1024] + args_pap).fetchone()
hechos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado = 'reemplazado'",
(raiz.lower(),)).fetchone()["n"]
if cam["n"]:
if not (cam["ana"] or 0):
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips",
que="Pasan a un formato unas 10 veces más ligero. El original no se toca hasta que reemplaces.",
cifra=f"{_plural(cam['n'], 'clip', 'clips')} · {_gb(cam['b'])}",
detalle="Primero hay que leerlos para saber cuáles conviene convertir. Va en segundo plano.",
ir="convertir")
else:
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips",
que="Pasan a un formato unas 10 veces más ligero. El original no se toca hasta que reemplaces.",
cifra=f"{_plural(cam['n'], 'clip', 'clips')} · {_gb(cam['b'])}",
detalle=f"{cam['ana']} ya están leídos" + (f" y {hechos} reemplazados." if hechos else "."),
ir="convertir")
# --- 8. respaldo, al final ---
dest = CONFIG.get("respaldo", {}).get("destino", "")
añadir(clave="respaldar", titulo="Copiar el respaldo a otro disco",
boton="Preparar respaldo", que="Una copia compacta con los clips ya convertidos, y un índice.",
cifra="", estado="espera" if any(a["estado"] == "toca" for a in out) else "toca",
detalle=(f"Destino: {dest}" if dest else "Falta indicar el disco de destino.") +
" Conviene hacerlo cuando lo demás esté hecho.",
ir="respaldar")
con.close()
# «lo seguro» son las acciones que se pueden hacer sin elegir nada: no entran convertir ni respaldar
auto = [a["clave"] for a in out if a["estado"] == "toca" and a["clave"] in ("limpiar", "fechas", "resto", "stock", "repetidos")]
return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc),
"automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None}
def estado_carpeta(ruta):
"""El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea."""
raiz = _clave(ruta)
r = raiz_cfg(raiz)
if not r:
return {"error": "carpeta no registrada"}
con = db()
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
trabajos = {x["estado"]: {"n": x["n"], "bytes_in": x["bi"] or 0, "bytes_out": x["bo"] or 0}
for x in con.execute("SELECT estado, COUNT(*) n, SUM(bytes_in) bi, SUM(bytes_out) bo FROM trabajos WHERE raiz = ? GROUP BY estado", (raiz,)).fetchall()}
analizados = con.execute("SELECT SUM(analizado = 1) ok, SUM(analizado = 0) sin FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','video')", (raiz,)).fetchone()
con.close()
prog = PROGRESO.get(raiz)
diag = asistente.diagnostico(raiz) if esc else None
g = {x["clave"]: x for x in (diag["grupos"] if diag else [])}
# último lote aplicado sobre esta carpeta
lotes = []
for e in leer_bitacora(3000):
if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower():
lotes.append({"lote": e.get("lote"), "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])})
lotes.sort(key=lambda x: -(x["ts"] or 0))
plan = PLANES.get(raiz)
plan_res = None
if plan:
filas = plan["filas"]
plan_res = {"filas": len(filas), "marcadas": sum(1 for f in filas if f.get("marcado")), "bytes": sum(f["bytes"] for f in filas),
"alta": sum(1 for f in filas if f["confianza"] == "alta"), "baja": sum(1 for f in filas if f["confianza"] == "baja"),
"avisos": plan.get("avisos") or []}
sin_nombre = [t for t in vocabulario(raiz) if not t.get("cubeta") and not t.get("dentro_pieza")] if esc else []
cam_bytes = (g.get("camara_raw", {}).get("bytes", 0) + g.get("camara_video", {}).get("bytes", 0))
resp_plan = PLANES_RESPALDO.get(raiz)
return {
"raiz": raiz, "cliente": r["cliente"], "tipo": r["tipo"],
"escaneo": dict(esc) if esc else None, "progreso": prog,
"diagnostico": diag,
"sin_nombre": [{"token": t["token"], "ejemplo": t["ejemplo"], "n": t["n"], "bytes": t["bytes"]} for t in sin_nombre[:12]],
"sin_nombre_n": len(sin_nombre),
"plan": plan_res, "lotes": lotes[:6],
"convertir": {"camara_bytes": cam_bytes, "raw_bytes": g.get("camara_raw", {}).get("bytes", 0), "trabajos": trabajos,
"analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0,
"cola_activa": COLA["activa"]},
"respaldo": {"destino": CONFIG.get("respaldo", {}).get("destino", ""), "plan": ({"n": resp_plan["n"], "bytes": resp_plan["bytes"], "base": resp_plan.get("base")} if resp_plan else None),
"estado": RESPALDO.get("estado"), "raw_pendientes": (resp_plan or {}).get("raw_omitidos", {}).get("n", 0)},
"asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None,
}
class Manejador(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
pass
def _json(self, datos, codigo=200):
cuerpo = json.dumps(datos, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(codigo)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(cuerpo)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(cuerpo)
def _cuerpo(self):
n = int(self.headers.get("Content-Length") or 0)
if not n:
return {}
try:
return json.loads(self.rfile.read(n).decode("utf-8"))
except ValueError:
return {}
def _es_local(self):
"""Solo acepta peticiones que vienen de la propia página (evita que otra web abierta en el navegador dé órdenes)."""
host = (self.headers.get("Host") or "").lower()
if host not in (f"127.0.0.1:{PUERTO}", f"localhost:{PUERTO}"):
return False
origen = self.headers.get("Origin")
if origen and origen.lower() not in (f"http://127.0.0.1:{PUERTO}", f"http://localhost:{PUERTO}"):
return False
return True
def do_GET(self):
if self.path.startswith("/api/") and not self._es_local():
return self._json({"error": "petición no permitida"}, 403)
u = urlparse(self.path)
q = {k: v[0] for k, v in parse_qs(u.query).items()}
ruta = u.path
try:
if ruta in ("/", "/index.html"):
with open(os.path.join(BASE, "static", "index.html"), "rb") as f:
cuerpo = f.read()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(cuerpo)))
self.end_headers()
self.wfile.write(cuerpo)
return
if ruta.startswith("/static/") and re.fullmatch(r"/static/[A-Za-z0-9_.-]+", ruta):
p = os.path.join(BASE, "static", ruta[len("/static/"):])
if not os.path.isfile(p):
return self._json({"error": "no existe"}, 404)
tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css",
".js": "text/javascript", ".woff2": "font/woff2"}
with open(p, "rb") as f:
cuerpo = f.read()
self.send_response(200)
self.send_header("Content-Type", tipos.get(os.path.splitext(p)[1].lower(), "application/octet-stream"))
self.send_header("Content-Length", str(len(cuerpo)))
self.send_header("Cache-Control", "max-age=86400")
self.end_headers()
self.wfile.write(cuerpo)
return
if ruta == "/api/estado":
return self._json(estado_general())
if ruta == "/api/soltado":
r = soltado(q.get("nombre", ""))
return self._json({"ruta": r, "es_archivo": bool(r) and os.path.isfile(r)})
if ruta == "/api/carpeta/acciones":
return self._json(acciones(q["raiz"]))
if ruta == "/api/carpeta/estado":
return self._json(estado_carpeta(q["raiz"]))
if ruta == "/api/mapa":
return self._json(mapa(q["raiz"]))
if ruta == "/api/actualizacion":
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
if ruta == "/api/asistente/pasos":
raiz = os.path.normpath(q["raiz"])
r = raiz_cfg(raiz)
if not r:
return self._json({"error": "raíz no registrada"}, 400)
d = asistente.diagnostico(raiz)
filas = Plan(r).correr()["filas"]
asistente.PLAN_CACHE[raiz] = filas
return self._json({"diagnostico": d, "pasos": asistente.pasos(d, filas), "movimientos": len(filas)})
if ruta == "/api/discos":
return self._json(discos())
if ruta == "/api/vocabulario":
return self._json({"tokens": vocabulario(os.path.normpath(q["raiz"])), "diccionario": DICC})
if ruta == "/api/diccionario":
return self._json(DICC)
if ruta == "/api/plan":
r = raiz_cfg(q["raiz"])
if not r:
return self._json({"error": "raíz no configurada"}, 400)
plan = Plan(r).correr()
PLANES[plan["raiz"]] = plan
return self._json(plan)
if ruta == "/api/bitacora":
ent = leer_bitacora(3000)
lotes = {}
for e in ent:
l = e.get("lote")
if not l:
continue
lo = lotes.setdefault(l, {"lote": l, "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})
if e.get("op") == "mover":
lo["movimientos"] += 1
if len(lo["ejemplos"]) < 5:
lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")})
if e.get("op") == "fin":
lo["raiz"] = e.get("raiz", "")
lo["errores"] = len(e.get("errores") or [])
if e.get("op") == "deshacer":
lotes.setdefault(e.get("de_lote"), {"lote": e.get("de_lote"), "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})["deshecho"] = True
return self._json({"lotes": sorted([v for k, v in lotes.items() if not k.endswith("-deshecho")], key=lambda x: -(x["ts"] or 0))})
if ruta == "/api/optimizar/candidatos":
o = CONFIG["optimizar"]
return self._json({"candidatos": candidatos(os.path.normpath(q["raiz"]), q.get("meses", o["meses_minimos"]), q.get("perfil", o["perfil"]),
q.get("min_mb", o["min_mb"]), q.get("exportados") == "1", q.get("formato", o.get("formato", "h265_10bit")))})
if ruta == "/api/optimizar/motores":
return self._json(motores())
if ruta == "/api/optimizar/cola":
con = db()
filas = [dict(r) for r in con.execute("SELECT * FROM trabajos ORDER BY id DESC LIMIT 500").fetchall()]
con.close()
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"]}})
if ruta == "/api/stock":
tipos = [t for t in (q.get("tipos") or "Video").split(",") if t]
return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
if ruta == "/api/papelera":
return self._json(papelera(os.path.normpath(q["raiz"])))
if ruta == "/api/vacias":
return self._json(carpetas_vacias(os.path.normpath(q["raiz"])))
if ruta == "/api/estructura/archivo":
return self._json(donde_iria(q.get("nombre", ""), q.get("tipo") or "contenido"))
if ruta == "/api/estructura/existen":
try:
segs = json.loads(q.get("ruta") or "[]")
except ValueError:
segs = []
cs = carpetas_como(os.path.normpath(q["raiz"]), segs) if segs else []
base = os.path.normpath(q["raiz"])
return self._json({"n": len(cs), "carpetas": [os.path.relpath(c, base) for c in cs[:20]]})
if ruta == "/api/estructura":
raiz = q.get("raiz")
tipo = q.get("tipo")
if raiz and not tipo:
r = raiz_cfg(os.path.normpath(raiz))
tipo = r.get("tipo") if r else None
return self._json(mapa_estructura(tipo or "contenido", os.path.normpath(raiz) if raiz else None))
if ruta == "/api/duplicados":
return self._json(duplicados(os.path.normpath(q["raiz"])))
if ruta == "/api/respaldo/plan":
raiz = os.path.normpath(q["raiz"])
destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "")
if not destino:
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0))
PLANES_RESPALDO[raiz] = plan
# calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
# reescribía config.json)
return self._json({k: v for k, v in plan.items() if k != "items"})
if ruta == "/api/respaldo/estado":
return self._json(RESPALDO)
if ruta == "/api/buscar-carpeta":
return self._json({"coincidencias": buscar_carpeta(q.get("nombre", ""))})
return self._json({"error": "no existe"}, 404)
except Exception as ex: # noqa
import traceback
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
def do_POST(self):
u = urlparse(self.path)
ruta = u.path
if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"):
return self._json({"error": "petición no permitida"}, 403)
d = self._cuerpo()
try:
if ruta == "/api/escanear":
return self._json({"lanzado": lanzar_escaneo(d["raiz"])})
if ruta == "/api/elegir-carpeta":
try:
return self._json({"ruta": elegir_carpeta()})
except ValueError as e:
return self._json({"error": str(e)}, 409)
if ruta == "/api/raiz/quitar":
try:
return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")), bool(d.get("descartar_cola"))))
except ErrorCola as e:
return self._json({"error": str(e), **e.datos}, 409)
except ValueError as e:
return self._json({"error": str(e)}, 409)
if ruta == "/api/raices":
choque = _anidadas(d.get("raices") or [])
if choque:
return self._json({"error": f"«{choque[1]}» está dentro de «{choque[0]}», que ya está en la lista. "
"Dos carpetas anidadas se pisan el inventario: deja solo una de las dos."}, 400)
CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True})
if ruta == "/api/clientes":
CONFIG["clientes"] = d["clientes"]
guardar_json(CONFIG_PATH, CONFIG)
_DISCOS_CACHE["ts"] = 0
return self._json({"ok": True})
if ruta == "/api/config":
# solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo"},
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
for k, v in d.items():
if k in permitidas and isinstance(v, dict):
CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]})
if isinstance(d.get("actualizaciones"), dict) and "auto" in d["actualizaciones"]:
CONFIG["actualizaciones"]["elegido"] = True # lo decidió una persona, no un valor por defecto
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True, "config": CONFIG})
if ruta == "/api/diccionario":
tok = R.norm(d.get("token", ""))
cub = d.get("cubeta")
if not tok:
return self._json({"error": "token vacío"}, 400)
with LOCK:
if cub in (None, ""):
DICC["carpetas"].pop(tok, None)
else:
DICC["carpetas"][tok] = cub
DICC["aprendido"].append({"ts": time.time(), "token": tok, "cubeta": cub})
guardar_json(DICC_PATH, DICC)
return self._json({"ok": True})
if ruta == "/api/regla":
with LOCK:
DICC["reglas"] = [r for r in DICC["reglas"] if not (R.norm(r.get("origen", "")) == R.norm(d.get("origen", "")) and r.get("cliente") == d.get("cliente"))]
if d.get("destino") is not None:
DICC["reglas"].append({"cliente": d.get("cliente"), "origen": d.get("origen"), "destino": d.get("destino"),
"cubeta": d.get("cubeta", "REVISAR"), "ts": time.time()})
guardar_json(DICC_PATH, DICC)
return self._json({"ok": True, "reglas": DICC["reglas"]})
if ruta == "/api/plantillas":
for t, cubs in (d.get("plantillas") or {}).items():
PLANTILLAS.setdefault(t, {}).update(cubs or {})
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
PLANES.clear() # los planes calculados apuntaban a los destinos viejos
return self._json({"ok": True})
if ruta == "/api/aplicar":
raiz = os.path.normpath(d["raiz"])
plan = PLANES.get(raiz)
if not plan:
return self._json({"error": "primero genera el plan"}, 400)
ids = set(d.get("ids", []))
filas = [f for f in plan["filas"] if f["id"] in ids]
# respetar destinos editados en la interfaz
for f in filas:
ed = (d.get("destinos") or {}).get(str(f["id"]))
if ed:
nuevo = os.path.normpath(os.path.join(raiz, ed))
if not dentro_de(raiz, nuevo):
return self._json({"error": f"el destino «{ed}» queda fuera de la carpeta del cliente"}, 400)
f["destino"] = nuevo
res = aplicar(raiz, filas)
PLANES.pop(raiz, None)
return self._json(res)
if ruta == "/api/deshacer":
return self._json(deshacer(d["lote"]))
if ruta == "/api/actualizacion/buscar":
actualizador.comprobar()
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
if ruta == "/api/actualizacion/instalar":
ok = actualizador.descargar_y_aplicar(en_hilo=True)
return self._json({"lanzado": bool(ok), **actualizador.ESTADO})
if ruta == "/api/acciones/hacer":
# «Hacer todo lo seguro»: el mismo motor que tenía el asistente, pero lanzado desde la
# lista de acciones y sin preguntar nada. Deja fuera lo que no debe ir en automático:
# convertir (tarda horas y hay que elegir formato) y respaldar (necesita otro disco).
raiz = os.path.normpath(d["raiz"])
if asistente.ESTADO["activo"]:
return self._json({"error": "ya hay algo en marcha en esta carpeta"}, 409)
diag = asistente.diagnostico(raiz)
filas = Plan(raiz_cfg(raiz)).correr()["filas"]
dec = {p["clave"]: p["defecto"] for p in asistente.pasos(diag, filas)}
dec["convertir"] = "no"
dec["respaldo"] = "no"
dec["carpetas"] = "seguras" # nunca las dudosas sin que alguien las mire
asistente.ejecutar(raiz, dec, filas, en_hilo=True)
return self._json({"lanzado": True, "hara": {k: v for k, v in dec.items() if v not in ("no", "dejar")}})
if ruta == "/api/asistente":
raiz = os.path.normpath(d["raiz"])
if asistente.ESTADO["activo"]:
return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"})
asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True)
return self._json({"lanzado": True})
if ruta == "/api/optimizar/analizar/detener":
a = ANALISIS.get(os.path.normpath(d["raiz"]))
if a:
a["cancelar"] = True
return self._json({"ok": bool(a)})
if ruta == "/api/optimizar/analizar":
raiz = os.path.normpath(d["raiz"])
p = ANALISIS.get(raiz)
if p and p["estado"] == "corriendo":
return self._json({"lanzado": False})
threading.Thread(target=analizar, args=(raiz, d.get("min_mb", CONFIG["optimizar"]["min_mb"])), daemon=True).start()
return self._json({"lanzado": True})
if ruta == "/api/optimizar/encolar":
o = CONFIG["optimizar"]
ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"]), d.get("formato", o.get("formato", "h265_10bit")))
if d.get("iniciar"):
iniciar_cola()
return self._json({"ids": ids})
if ruta == "/api/optimizar/control":
acc = d.get("accion")
if acc == "iniciar":
return self._json({"ok": iniciar_cola()})
if acc == "detener":
COLA["cancelar"] = True
COLA["activa"] = False
return self._json({"ok": True})
if acc == "quitar":
con = db()
with con:
con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", []))
con.close()
return self._json({"ok": True})
if acc == "reintentar":
con = db()
with con:
con.execute(f"UPDATE trabajos SET estado = 'pendiente', pct = 0, log = '' WHERE estado IN ('error','cancelado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", []))
con.close()
return self._json({"ok": True})
if ruta == "/api/optimizar/verificar":
return self._json({"resultados": verificar(d.get("ids", []))})
if ruta == "/api/optimizar/reemplazar":
return self._json({"resultados": reemplazar(d.get("ids", []))})
if ruta == "/api/stock/papelera":
return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", [])))
if ruta == "/api/estructura/renombrar":
return self._json(renombrar_nodo(d.get("tipo") or "contenido", d.get("ruta") or [],
d.get("nombre", ""), d.get("raiz"), bool(d.get("en_disco"))))
if ruta == "/api/colores/pintar":
return self._json(pintar_carpetas(os.path.normpath(d["raiz"])))
if ruta == "/api/colores/quitar":
return self._json(despintar_carpetas(os.path.normpath(d["raiz"])))
if ruta == "/api/vacias/borrar":
return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", [])))
if ruta == "/api/duplicados/apartar":
return self._json(apartar_duplicados(os.path.normpath(d["raiz"])))
if ruta == "/api/abrir":
return self._json(abrir_carpeta(d.get("ruta", "")))
if ruta == "/api/respaldo/copiar":
_rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", "")))
if _rp and _rp.get("destino_base"):
CONFIG.setdefault("respaldo", {})["destino"] = _rp["destino_base"]
guardar_json(CONFIG_PATH, CONFIG)
raiz = os.path.normpath(d["raiz"])
plan = PLANES_RESPALDO.get(raiz)
if not plan:
return self._json({"error": "primero calcula el respaldo"}, 400)
if RESPALDO["estado"] == "corriendo":
return self._json({"error": "ya hay una copia en curso"}, 400)
threading.Thread(target=copiar_respaldo, args=(raiz, plan), daemon=True).start()
return self._json({"ok": True})
if ruta == "/api/respaldo/detener":
RESPALDO["cancelar"] = True
return self._json({"ok": True})
return self._json({"error": "no existe"}, 404)
except Exception as ex: # noqa
import traceback
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
# ==========================================================================
# Línea de comandos: organizar una carpeta con un solo comando
# ==========================================================================
AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando
OrganizadorLabstream.exe abre la interfaz en el navegador
OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra
de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer
OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar)
OrganizadorLabstream.exe organizar "ruta" [opciones]
--cliente "Nombre" nombre del cliente (por defecto, el de la carpeta)
--tipo contenido|pauta|proyecto|interno (por defecto contenido)
--solo-alta aplica solo los movimientos de confianza alta
--todo aplica también los de confianza baja y los cosméticos
--simular solo muestra el plan, no mueve nada
--si no pregunta: aplica directamente
OrganizadorLabstream.exe escanear "ruta" solo inventaría (para ver el mapa en la interfaz)
OrganizadorLabstream.exe stock "ruta" [--meses 3] [--todo-tipo] [--si] aparta el stock de Envato viejo a _Para borrar/stock
OrganizadorLabstream.exe respaldo "ruta" "destino" [--con-raw] [--con-stock] copia compacta a otro disco
OrganizadorLabstream.exe deshacer LOTE deshace un lote de la bitácora
OrganizadorLabstream.exe actualizar [--si] busca versión nueva en Labstream OS y la instala
OrganizadorLabstream.exe duplicados "ruta" [--si] lista los duplicados; con --si los junta en subcarpetas Duplicados
La app NUNCA borra: lo que sobra va a _Para borrar dentro de la misma carpeta (la borra una persona a mano) y todo queda en datos\\bitacora.jsonl.
"""
def _gb(b):
b = b or 0
if b >= 1e12:
return f"{b / 1e12:.2f} TB"
return f"{b / 1e9:.1f} GB" if b >= 1e8 else f"{b / 1e6:.0f} MB"
def _opcion(args, nombre, defecto=None):
if nombre in args:
i = args.index(nombre)
return args[i + 1] if i + 1 < len(args) else defecto
return defecto
def _confirmar(pregunta, auto):
if auto:
return True
try:
return input(pregunta + " [S/n] ").strip().lower() in ("", "s", "si", "sí", "y")
except EOFError:
return False
def cli_escanear(ruta):
ruta = os.path.normpath(ruta)
print(f"Escaneando {ruta} …", flush=True)
escanear(ruta)
p = PROGRESO[ruta]
if p["estado"] != "listo":
print("ERROR al escanear:", p.get("error"))
return False
print(f" {p['n']:,} archivos · {_gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s")
return True
def cli_raiz(ruta, args):
"""asegura que la carpeta esté registrada como raíz (la crea si es nueva)"""
ruta = os.path.normpath(ruta)
if not os.path.isdir(ruta):
print("No existe la carpeta:", ruta)
return None
r = raiz_cfg(ruta)
if not r:
nombre = os.path.basename(ruta.rstrip("\\/")) or ruta
cliente = _opcion(args, "--cliente") or next((c for c, i in CONFIG["clientes"].items()
if any(R.norm(a) == R.norm(nombre) for a in i.get("alias", []) + [c])), nombre)
tipo = _opcion(args, "--tipo") or CONFIG["clientes"].get(cliente, {}).get("tipo", "contenido")
r = {"ruta": ruta, "cliente": cliente, "tipo": tipo}
CONFIG["raices"].append(r)
CONFIG["clientes"].setdefault(cliente, {"tipo": tipo, "alias": [nombre]})
guardar_json(CONFIG_PATH, CONFIG)
print(f"Carpeta nueva registrada como raíz: cliente «{cliente}», tipo {tipo}")
return r
def cli_organizar(ruta, args):
r = cli_raiz(ruta, args)
if not r or not cli_escanear(r["ruta"]):
return 1
plan = Plan(r).correr()
filas = plan["filas"]
if not filas:
print("Nada que mover: la carpeta ya está en orden.")
return 0
if "--todo" in args:
elegidas = filas
elif "--solo-alta" in args:
elegidas = [f for f in filas if f["confianza"] == "alta"]
else:
elegidas = [f for f in filas if f["marcado"]]
print(f"\nPLAN: {len(filas)} movimientos propuestos; se aplicarían {len(elegidas)} "
f"({_gb(sum(f['bytes'] for f in elegidas))}, {sum(f['n'] for f in elegidas):,} archivos)\n")
for f in sorted(elegidas, key=lambda f: -f["bytes"])[:60]:
n = f" [{len(f['archivos'])} archivos]" if f["archivos"] else ""
print(f" {f['confianza']:5} {f['cubeta']:10} {_gb(f['bytes']):>8} {f['origen_rel']}{n}\n{'':28}-> {f['destino_rel']} ({f['motivo'][:70]})")
if len(elegidas) > 60:
print(f" … y {len(elegidas) - 60} más")
omitidas = [f for f in filas if f not in elegidas]
if omitidas:
print(f"\n Sin aplicar (confianza baja o cosméticos): {len(omitidas)}. Con --todo se aplican; con la interfaz se revisan uno a uno.")
for a in plan["avisos"]:
print(" AVISO:", a)
if "--simular" in args:
print("\nSimulación: no se movió nada.")
return 0
if not elegidas:
return 0
if not _confirmar("\n¿Aplicar estos movimientos? (los archivos no se renombran; lo sobrante va a _Para borrar)", "--si" in args):
print("Cancelado. No se movió nada.")
return 0
res = aplicar(r["ruta"], elegidas)
print(f"\nHecho: lote {res['lote']} · {res['movimientos']} movimientos · {len(res['errores'])} errores")
for e in res["errores"][:20]:
print(" ERROR:", e)
print(f"Para deshacer: OrganizadorLabstream.exe deshacer {res['lote']}")
return 0 if not res["errores"] else 2
def cli_stock(ruta, args):
r = cli_raiz(ruta, args)
if not r or not cli_escanear(r["ruta"]):
return 1
meses = int(_opcion(args, "--meses", CONFIG["optimizar"]["meses_minimos"]))
tipos = ["Video", "Audio", "Imagenes"] if "--todo-tipo" in args else ["Video"]
s = stock_viejo(r["ruta"], meses, tipos)
print(f"\nStock de Envato en proyectos anteriores a {s['tope']}: {s['n']} archivos · {_gb(s['bytes'])}")
for g in s["grupos"][:40]:
print(f" {_gb(g['bytes']):>8} {g['carpeta']} ({g['n']}: {', '.join(g['ejemplos'])[:80]})")
if not s["n"] or "--simular" in args:
return 0
if not _confirmar("¿Apartar todo a _Para borrar/stock? (reversible)", "--si" in args):
return 0
res = stock_a_papelera(r["ruta"], [x for g in s["grupos"] for x in g["rutas"]])
print(f"Hecho: lote {res['lote']} · {res['movimientos']} archivos apartados. Están en _Para borrar\\stock: revísalos y bórralos a mano.")
return 0
def cli_respaldo(ruta, destino, args):
r = cli_raiz(ruta, args)
if not r or not cli_escanear(r["ruta"]):
return 1
plan = plan_respaldo(r["ruta"], destino, True, "--con-raw" in args, "--con-stock" not in args)
print(f"\nRespaldo de «{plan['cliente']}» → {plan['base']}: {plan['n']:,} archivos · {_gb(plan['bytes'])}")
print(" por tipo:", ", ".join(f"{t['tipo']} {t['n']} ({_gb(t['bytes'])})" for t in plan["por_tipo"]))
if plan["raw_omitidos"]["n"]:
print(f" NO se copian {plan['raw_omitidos']['n']} originales de cámara sin convertir ({_gb(plan['raw_omitidos']['bytes'])}); conviértelos antes o usa --con-raw")
if "--simular" in args or not _confirmar("¿Copiar?", "--si" in args):
return 0
hilo = threading.Thread(target=copiar_respaldo, args=(r["ruta"], plan), daemon=True)
hilo.start()
ultimo = -1
while hilo.is_alive():
time.sleep(2)
pct = int(RESPALDO["bytes"] / RESPALDO["bytes_total"] * 100) if RESPALDO["bytes_total"] else 0
if pct != ultimo:
print(f" {pct:3d}% {RESPALDO['hechos']}/{RESPALDO['total']} {RESPALDO['actual'][-60:]}", flush=True)
ultimo = pct
print(f"\n{RESPALDO['estado']}: {RESPALDO['copiados']} copiados, {RESPALDO['saltados']} ya estaban, {len(RESPALDO['errores'])} errores. Índice en {plan['base']}\\INDICE.csv")
return 0
def cli(args):
cmd = args[0].lower() if args else ""
resto = args[1:]
if cmd in ("-h", "--help", "ayuda", "/?"):
print(AYUDA)
return 0
if os.path.isdir(args[0]) and cmd not in ("organizar", "escanear", "stock", "respaldo", "deshacer", "duplicados", "asistente"):
return asistente.cli(args[0], resto) # arrastraron una carpeta encima del .exe
if cmd == "asistente" and resto:
return asistente.cli(resto[0], resto[1:])
if cmd in ("actualizar", "actualizacion"):
return actualizador.cli(resto)
if cmd == "organizar" and resto:
return cli_organizar(resto[0], resto[1:])
if cmd == "escanear" and resto:
r = cli_raiz(resto[0], resto[1:])
return 0 if r and cli_escanear(r["ruta"]) else 1
if cmd == "stock" and resto:
return cli_stock(resto[0], resto[1:])
if cmd == "respaldo" and len(resto) >= 2:
return cli_respaldo(resto[0], resto[1], resto[2:])
if cmd == "deshacer" and resto:
res = deshacer(resto[0])
print(f"Deshechos {res['deshechos']} movimientos · {len(res['errores'])} errores")
return 0
if cmd == "duplicados" and resto:
d = duplicados(os.path.normpath(resto[0]))
print(f"{len(d['grupos'])} grupos · {d['n']} copias · {d['bytes'] / 1e9:.1f} GB ({d['identicos']} idénticos, {d['versiones']} versiones MP4/MOV)")
for g in d["grupos"][:40]:
print(f" [{g['tipo']}] {g['nombre']} queda: {g['queda']} → {len(g['mueven'])} a {g['destino']}")
if "--si" in resto and d["grupos"]:
print(apartar_duplicados(os.path.normpath(resto[0]), d["grupos"]))
return 0
print(AYUDA)
return 1
def _asegurar_consola():
"""El .exe es de ventana (sin consola). Para los comandos y el arrastrar-carpeta hace falta una:
se engancha a la del padre (cmd/PowerShell) o se abre una nueva."""
if not CONGELADO or os.name != "nt":
return
try:
import ctypes
k = ctypes.windll.kernel32
if k.GetConsoleWindow(): # ya hay consola
return
h = k.GetStdHandle(-11)
if h and h != -1 and k.GetFileType(h) in (1, 3): # salida a archivo o tubería: se respeta
return
if not k.AttachConsole(-1):
k.AllocConsole()
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace", buffering=1)
sys.stderr = sys.stdout
sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace")
k.SetConsoleTitleW("OrganiLAB")
except Exception: # noqa
pass
SOLTADOS = {"ts": 0, "rutas": []}
def _al_soltar(e):
try:
rutas = [f.get("pywebviewFullPath") for f in (e.get("dataTransfer") or {}).get("files", []) if f.get("pywebviewFullPath")]
except Exception: # noqa
rutas = []
if rutas:
SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas])
def soltado(nombre, espera=2.5):
"""Ruta completa de lo que se acaba de soltar en la ventana (llega por el puente WebView2). Espera un poco
porque el drop del navegador y el aviso del puente no llegan en el mismo orden."""
fin = time.time() + espera
while time.time() < fin:
if time.time() - SOLTADOS["ts"] < 8:
for r in SOLTADOS["rutas"]:
if not nombre or os.path.basename(r.rstrip("\\/")).lower() == nombre.lower():
return r
time.sleep(0.1)
return None
def _ventana(url):
"""Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador."""
try:
import webview
except Exception: # noqa
webbrowser.open(url)
return False
try:
v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff")
def _al_cargar():
# WebView2 entrega la ruta completa de lo que se suelta solo por este puente (el navegador no la da):
# se guarda y la interfaz la pide en /api/soltado en cuanto ve el drop.
try:
v.dom.document.events.drop += _al_soltar
except Exception as ex: # noqa
print("sin puente de arrastre:", ex)
v.events.loaded += _al_cargar
webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana"))
return True
except Exception as ex: # noqa
print("Sin WebView2; abro el navegador:", ex)
webbrowser.open(url)
return False
def main():
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")]
if args_tmp and "--puente" not in sys.argv:
_asegurar_consola()
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception: # noqa
pass
if "--puente" in sys.argv: # modo puente con Resolve (lo usa el propio .exe)
import resolve_bridge
sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1]))
args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")]
if args:
if CONGELADO and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?"):
actualizador.comprobar_en_segundo_plano()
codigo = cli(args)
d = actualizador.ESTADO.get("disponible")
if d:
print(f"\nHay una versión nueva del Organizador ({d['version']}). Para instalarla: OrganizadorLabstream.exe actualizar")
if CONGELADO and "--si" not in args and sys.stdin and sys.stdin.isatty():
try:
input("\nPulsa Enter para cerrar.")
except EOFError:
pass
sys.exit(codigo)
h = herramientas()
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)")
huerfanas = recuperar_huerfanas()
if huerfanas:
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
try:
srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador)
except OSError:
print(f"Ya hay un Organizador abierto en el puerto {PUERTO}; abro esa ventana.")
webbrowser.open(f"http://127.0.0.1:{PUERTO}/")
return
url = f"http://127.0.0.1:{PUERTO}/"
if sys.stdout:
print(f"OrganiLAB {VERSION} en {url} (Ctrl+C para cerrar)")
if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True:
actualizador.comprobar_en_segundo_plano()
if "--sin-navegador" in sys.argv: # solo el servidor (desarrollo / vista previa)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return
hilo = threading.Thread(target=srv.serve_forever, daemon=True)
hilo.start()
if "--navegador" in sys.argv:
webbrowser.open(url)
try:
hilo.join()
except KeyboardInterrupt:
pass
return
# ventana propia: al cerrarla se cierra el programa (salvo que haya trabajo en marcha)
_ventana(url)
while COLA["activa"] or RESPALDO.get("estado") == "corriendo" or asistente.ESTADO.get("activo"):
time.sleep(1)
srv.shutdown()
if __name__ == "__main__":
main()