Files
labstream-organizador/organizador.py
Labstream 93b8f4843d 3.6.0: grabaciones siempre por día y por fuente, grabadoras en su carpeta, y «Organizar carpetas»
Las grabaciones van siempre a su día de rodaje y dentro a una carpeta por fuente; el DJI Mic, la Tascam y
las Zoom se reconocen por su nombre y van a DJI_MIC, TASCAM y ZOOM en vez de caer juntas en AUDIO. El plan
repasa lo ya ordenado sin carpeta de día o con la grabadora en AUDIO. «Mapa» pasa a «Organizar carpetas»:
el árbol salía en fila por una regla CSS del ejemplo de tipo de trabajo; ahora va en vertical a toda la
altura y a la derecha, fijos, «Qué va en esta carpeta» y «¿Dónde va a parar este archivo?».

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 06:11:34 -05:00

7127 líneas
368 KiB
Python
Original Blame Histórico

Este archivo contiene caracteres Unicode ambiguos

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+). Es un programa de Windows: abre su
propia ventana (ventana.py) y la interfaz habla con este módulo por un servidor
que solo escucha en este equipo.
OrganizadorLabstream.exe (el paquete: lo arma empaquetar.py)
py -3.12 organizador.py (desde el código)
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 socket
import stat as stat_mod
import hashlib
import sqlite3
import threading
import subprocess
import tempfile
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 braw
import asistente
import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "3.6.0"
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
# el intérprete de Python tal como lo firma la Python Software Foundation, con otro nombre (arranque.py
# cuenta cómo). Hasta la 2.x era un .exe de PyInstaller, sin firma, y Smart App Control lo bloqueaba.
CONGELADO = bool(getattr(sys, "frozen", False)) or os.path.normcase(os.path.dirname(os.path.abspath(sys.executable))) == os.path.normcase(BASE)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras
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
"braw": "", # carpeta del decodificador de Blackmagic RAW; vacío = se busca sola
},
# las subcarpetas con las que nace una pieza nueva (las que más se repiten en las piezas del estudio)
"pieza": {"subcarpetas": ["Guion", "Videos", "Imagenes", "Musica", "Elements", "IA", "Portada", "Exportado"]},
# el orden automático viene apagado: lo enciende una persona, y entonces Windows lanza la pasada a esa hora.
# «limite» es cuántos archivos puede mover de una carpeta sin que nadie lo mire.
"automatico": {"activo": False, "hora": "02:00", "limite": 3000},
}
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}
_BRAW = {"ts": 0, "datos": None}
def _lector_braw(*args, espera=120):
"""Lanza braw.py —proceso aparte: ahí se carga el decodificador de Blackmagic, no aquí— y devuelve lo que
dijo, una línea JSON por evento."""
carpeta = CONFIG["optimizar"].get("braw") or ""
r = subprocess.run([sys.executable, os.path.join(BASE, "braw.py"), *args], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=espera, creationflags=SIN_VENTANA,
env=dict(os.environ, ORGANILAB_BRAW=carpeta) if carpeta else None)
out = []
for linea in (r.stdout or "").splitlines():
try:
out.append(json.loads(linea))
except ValueError:
pass
return out
def decodificador_braw():
"""¿Se pueden leer los BRAW sin Resolve? Lo dice braw.py después de cargar el decodificador de Blackmagic
y pedirle las interfaces que se usan. La respuesta se recuerda diez minutos."""
if _BRAW["datos"] and time.time() - _BRAW["ts"] < 600:
return _BRAW["datos"]
carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "")
datos = {"ok": False, "carpeta": "", "motivo": "no está el decodificador de Blackmagic RAW: se instala con DaVinci Resolve (vale el gratuito)"}
if carpeta:
try:
dicho = _lector_braw("sonda", carpeta, espera=60)
datos = dicho[-1] if dicho else {"ok": False, "carpeta": carpeta, "motivo": "el decodificador de Blackmagic RAW no respondió"}
except Exception as ex: # noqa
datos = {"ok": False, "carpeta": carpeta, "motivo": f"el decodificador de Blackmagic RAW no respondió: {ex}"}
_BRAW.update(ts=time.time(), datos=datos)
return datos
def medir_braw(ruta):
"""Lo que el decodificador dice de un BRAW: su tamaño de verdad (ffprobe lo lee con relleno y sin girar:
4128×2176 para un clip que es 2160×4096 en vertical), sus cuadros, su código de tiempo y su curva."""
try:
dicho = _lector_braw("info", ruta)
except Exception as ex: # noqa
return {"ok": False, "error": f"el lector de BRAW no respondió: {ex}"}
return dicho[-1] if dicho else {"ok": False, "error": "el lector de BRAW no dijo nada"}
def _curva(m):
return (m.get("gamma") or "BRAW").replace("Blackmagic Design ", "")
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"))
if out["gpu"]["hevc"]:
try: # ¿la tarjeta y su controlador admiten el ajuste «uhq»? (ver _args_video)
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", "p010le", "-tune", "uhq", "-profile:v", "main10",
"-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
out["gpu"]["uhq"] = r.returncode == 0
except Exception: # noqa
out["gpu"]["uhq"] = False
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
out["braw"] = decodificador_braw()
_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 _sufijos_conversion():
"""El sufijo del convertidor se puede cambiar en Ajustes: las reglas tienen que conocerlo para saber
que «C0001_ligero2.mp4» sigue siendo el clip C0001 y no un exportado."""
propio = (CONFIG["optimizar"].get("sufijo") or "").strip().lower()
if len(propio) > 2 and propio not in R.SUFIJOS_CONVERSION:
R.SUFIJOS_CONVERSION = R.SUFIJOS_CONVERSION + (propio,)
_sufijos_conversion()
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, el decodificador de Blackmagic RAW 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"}
# solo se mira si el archivo está: cargarlo de verdad lo hace decodificador_braw(), en un proceso aparte
carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "")
out["braw"] = {"ok": bool(carpeta), "ruta": carpeta}
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)
# Hasta la 3.1.1 «Por nombrar» preguntaba también por las carpetas de la propia app («basura», de
# _Para borrar\basura) y proponía «grabación» si dentro había clips. Lo que se aprendió así se olvida:
# nunca fue una carpeta del cliente.
for k in [k for k in DICC["carpetas"] if k in R.NOMBRES_DE_LA_APP and k not in R.DICCIONARIO_SEMILLA]:
del DICC["carpetas"][k]
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",
# con qué versión de las reglas se hizo el inventario (R.VERSION_REGLAS)
"ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0",
# «primero este»: la cola va por orden descendente y luego por id
"ALTER TABLE trabajos ADD COLUMN orden INTEGER DEFAULT 0",
# la nota de calidad (VMAF) medida en «comparar»: media y cuadro peor
"ALTER TABLE trabajos ADD COLUMN nota REAL", "ALTER TABLE trabajos ADD COLUMN nota_min REAL"):
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):
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
def subir(d):
"""`d` y todas las carpetas de encima, hasta la raíz."""
while True:
yield d
if os.path.normpath(d).lower() == raiz.lower():
return
padre = os.path.dirname(d)
if padre == d:
return
d = padre
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
# dentro de la M4ROOT de una tarjeta Sony, SUB son los proxies que graba la cámara y
# THMBNL y GENERAL sus miniaturas e índices: no son material
sony = os.path.basename(path).upper() == "M4ROOT"
p = en_proxy or R.es_proxy_dir(nombre) or (sony and nombre.upper() == "SUB")
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM \
or (sony and nombre.upper() in ("THMBNL", "GENERAL"))
# 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))
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
for d in subir(path):
agrega_dir(d, st.st_size)
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)
_fechas_de_rodaje(filas)
_heredar_fechas(filas)
# de qué mes es cada carpeta: se cuenta al final, con las fechas ya corregidas, y sin la basura
# ni los proxies (un .DS_Store de otro año no dice nada del material)
for f in filas:
if f[14] or f[15]:
continue
am = (int(f[12][:4]), int(f[12][5:7]))
for d in subir(f[2]):
dirs[d][2][am] += 1
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 / sum(fechas.values())
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 (raiz, ts, n, bytes, seg, reglas) VALUES (?,?,?,?,?,?)",
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"], R.VERSION_REGLAS))
con.close()
prog["estado"] = "listo"
if fallos:
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
olvidar_plan(raiz, recalcular=False) # 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):
orig = None
# primero el original del todo («C0001» para «C0001_10bit_10bit.mp4») y, si no está, el de un
# paso atrás: una conversión de una conversión no debe quedarse con la fecha de la primera
for base in dict.fromkeys(b for b in (R.raiz_derivado(f[I_NOMBRE]), R.base_derivado(f[I_NOMBRE])) if b):
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 not None:
break
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 _fechas_de_rodaje(filas):
"""Los clips de Blackmagic se llaman por la tarjeta, el mes y el día («A014_1209…»), sin año. El año
sale de la fecha del archivo, y esa cambia cuando una conversión o una copia lo reescriben.
Todos los clips de la misma tarjeta y el mismo día son del mismo rodaje, así que comparten año: si
uno conserva su fecha de cámara, ese año es seguro para todos; si ninguno, vale el más antiguo, porque
un clip no puede ser posterior a ninguna de sus copias. Medido el 4-oct-2026 sobre el material real:
un mismo rodaje del 2 de marzo estaba partido entre 2025 y 2026 solo porque la mitad de los clips se
había vuelto a guardar en julio."""
I_NOMBRE, I_FECHA, I_ORIGEN = 3, 12, 13
grupos = defaultdict(list)
for i, f in enumerate(filas):
if f[I_ORIGEN] in ("nombre", "nombre+mtime"):
clave = R.clave_rodaje(f[I_NOMBRE])
if clave:
grupos[clave].append(i)
cambiadas = 0
for idx in grupos.values():
seguras = {filas[i][I_FECHA] for i in idx if filas[i][I_ORIGEN] == "nombre"}
if len(seguras) > 1:
continue # la misma tarjeta y día en dos años distintos: no se decide
fecha = next(iter(seguras)) if seguras else min(filas[i][I_FECHA] for i in idx)
origen = "nombre" if seguras else "nombre+mtime"
for i in idx:
f = filas[i]
if f[I_FECHA] == fecha and f[I_ORIGEN] == origen:
continue
nueva = list(f)
nueva[I_FECHA], nueva[I_ORIGEN] = fecha, origen
filas[i] = tuple(nueva)
cambiadas += 1
# Un rodaje que pasa de medianoche es un solo rodaje. El nombre trae también la hora («A006_05312107»,
# «A006_06010000»): lo grabado de madrugada cuenta como del día en que se empezó, si ese día se
# estaba grabando por la noche. Medido el 4-oct-2026: un rodaje del 31 de mayo a las 21:07 al 1 de
# junio a las 04:00 salía partido en dos meses, 63 clips en mayo y 103 en junio.
de_noche = set()
for idx in grupos.values():
for i in idx:
try:
if int(filas[i][I_NOMBRE][9:11]) >= 18:
de_noche.add(filas[i][I_FECHA])
except ValueError:
pass
for idx in grupos.values():
for i in idx:
f = filas[i]
try:
if int(f[I_NOMBRE][9:11]) >= 6:
continue
vispera = (datetime.date.fromisoformat(f[I_FECHA]) - datetime.timedelta(days=1)).isoformat()
except ValueError:
continue
if vispera in de_noche:
nueva = list(f)
nueva[I_FECHA] = vispera
filas[i] = tuple(nueva)
cambiadas += 1
return cambiadas
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)
hueco = vacio in s
s = re.sub(r"[-_ ]*\x00", "", s)
# los separadores sueltos solo se recortan si los dejó una variable vacía: sin esto, una ruta que
# de verdad empieza por guion bajo («_Entrada\Tarjeta») llegaba a la papelera como «Entrada\Tarjeta»
if hueco and seg.startswith("{"):
s = s.lstrip("-_ ")
if hueco and seg.endswith("}"):
s = s.rstrip("-_ ")
if s:
segmentos.append(s)
if not segmentos:
segmentos = ["_Sin fecha"]
return os.sep.join(segmentos)
# ==========================================================================
# Qué es una carpeta por lo que tiene dentro
# ==========================================================================
def _mes_claro(nombre):
"""True si la carpeta se llama como un mes sin lugar a dudas («Mayo», «11-Noviembre», «Marzo 2026»).
«Mar», «May» o «05» pueden ser otra cosa y no cuentan."""
m, dudoso = R.detectar_mes(re.sub(r"20\d\d", " ", nombre or "").strip(" -_"))
return bool(m) and not dudoso
class Estructura:
"""El árbol de una carpeta de cliente tal como quedó en el inventario, con lo que cuelga de cada rama.
El nombre de una carpeta no basta para saber qué es. Medido el 4-oct-2026 sobre el material real:
«Orden» (533 archivos, 65 GB) se aprendió como grabación porque casi todo su peso eran clips, y el plan
la iba a desarmar entera por cámara y por mes; dentro había una organización anterior completa, con
sus grabaciones, sus proyectos por mes y sus portadas. Aquí se mira la forma: una tarjeta volcada es
casi todo clips de cámara; una pieza trae su carpeta de exportados, de portada y de música; y una
carpeta que guarda meses o años dentro es un cajón que hay que abrir, no una cosa que mover."""
def __init__(self, con, raiz, dicc):
self.raiz = os.path.normpath(raiz)
self.dicc = dicc
self.carp = {} # ruta (normcase) -> fila de `carpetas`
self.hijos_de = defaultdict(list) # ruta (normcase) -> rutas de sus subcarpetas
tope = os.path.normcase(self.raiz)
for c in con.execute("SELECT * FROM carpetas WHERE raiz = ?", (self.raiz,)).fetchall():
k = os.path.normcase(os.path.normpath(c["ruta"]))
self.carp[k] = c
if k != tope:
self.hijos_de[os.path.normcase(os.path.dirname(os.path.normpath(c["ruta"])))].append(os.path.normpath(c["ruta"]))
# por carpeta, contando lo de sus subcarpetas: [archivos, de cámara, de rodaje, proyectos]
self.cuenta = defaultdict(lambda: [0, 0, 0, 0])
self.anios = defaultdict(Counter) # ruta -> cuántos archivos de cada año hay debajo
for f in con.execute("SELECT dir, rol, fuente, substr(fecha, 1, 4) anio, COUNT(*) n FROM archivos WHERE raiz = ? "
"AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol, fuente, anio", (self.raiz,)).fetchall():
cam = f["fuente"] in R.FUENTE_CARPETA or f["rol"] in R.ROLES_CAMARA
rod = f["rol"] in R.ROLES_RODAJE
proy = f["rol"] in ("proyecto", "diseno")
d = os.path.normpath(f["dir"])
while True:
e = self.cuenta[os.path.normcase(d)]
e[0] += f["n"]
e[1] += f["n"] if cam else 0
e[2] += f["n"] if rod else 0
e[3] += f["n"] if proy else 0
if f["anio"]:
self.anios[os.path.normcase(d)][f["anio"]] += f["n"]
if os.path.normcase(d) == tope:
break
padre = os.path.dirname(d)
if padre == d:
break
d = padre
def _k(self, ruta):
return os.path.normcase(os.path.normpath(ruta))
def utiles(self, ruta):
"""Archivos que hay debajo, sin contar basura ni proxies."""
return self.cuenta[self._k(ruta)][0] if self._k(ruta) in self.cuenta else 0
def hijos(self, ruta):
return self.hijos_de.get(self._k(ruta), [])
def anio_de(self, ruta):
"""El año del que es la mayor parte de lo que hay debajo, o None si la carpeta está vacía."""
c = self.anios.get(self._k(ruta))
if not c:
return None
try:
return int(c.most_common(1)[0][0])
except ValueError:
return None
def debajo(self, ruta, hondo=3):
"""Las subcarpetas hasta `hondo` niveles."""
nivel = [os.path.normpath(ruta)]
for _ in range(hondo):
nivel = [h for p in nivel for h in self.hijos(p)]
if not nivel:
return
yield from nivel
def fechas_dentro(self, ruta, hondo=3):
"""Carpetas de año o de mes que hay debajo y tienen material: la señal de que esto guarda varias
cosas de distintas fechas y hay que mirarlo por dentro."""
return [d for d in self.debajo(ruta, hondo)
if self.utiles(d) and (R.detectar_anio(os.path.basename(d)) or _mes_claro(os.path.basename(d)))]
def partes(self, ruta, hondo=3):
"""Qué partes de una pieza montada hay entre sus subcarpetas (exportados, portada, stock, música…)."""
out = set()
for d in self.debajo(ruta, hondo):
if not self.utiles(d):
continue
cub, _ = R.cubeta_carpeta(os.path.basename(d), self.dicc)
if cub in R.PARTES_PIEZA:
out.add(cub)
return out
def es_rodaje(self, ruta, por_nombre=False):
"""¿Una tarjeta volcada, o una carpeta con algo montado dentro?
`por_nombre` es True cuando el nombre ya dice que es una grabación con una palabra de fábrica
(«Grabación», «URSA», «Tomas»): entonces basta con que casi todo sea material, aunque la cámara
no se reconozca. Si no, se exige que casi todo sean clips de cámara reconocidos."""
n, cam, rod, proy = self.cuenta[self._k(ruta)] if self._k(ruta) in self.cuenta else (0, 0, 0, 0)
if not n:
return por_nombre
if len(self.partes(ruta)) >= 2:
return False
return rod >= 0.6 * n if por_nombre else cam >= 0.8 * n
def _propias(tipo):
"""Los nombres de carpeta que ya son de la estructura para este tipo de cliente: los que van en la
raíz y los que van dentro de un mes. Salen de las plantillas en uso, no de una lista fija."""
primeros, en_mes = set(), set()
for p in PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).values():
segs = p.split("/")
if "{" not in segs[0]:
primeros.add(segs[0].lower())
for a, b in zip(segs, segs[1:]):
if a == "{mes}" and "{" not in b:
en_mes.add(b.lower())
return primeros, en_mes
def _es_propia(nombre, nivel, primeros, en_mes):
"""True si la carpeta ya es de la estructura y no hay que volver a ordenarla."""
if nombre.startswith("_") or R.norm(nombre) in R.CARPETAS_PROPIAS:
return True
n = nombre.lower()
return (nivel == "raiz" and n in primeros) or (nivel == "mes" and n in en_mes)
def _de_fabrica(nombre, cubeta):
"""True si el nombre dice esa cubeta por una palabra de fábrica («Grabación», «URSA») y no porque
alguien lo haya confirmado en «Por nombrar»."""
return R.cubeta_carpeta(nombre, R.DICCIONARIO_SEMILLA)[0] == cubeta
def _fecha_de_camara(f):
"""La fecha de modificación de un original de cámara es la del rodaje: nadie lo reescribe. No vale
para lo que sale del convertidor ni para lo que no se sabe de qué cámara viene."""
return (f["fuente"] in R.FUENTE_CARPETA
and f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara")
and not R.es_conversion(f["nombre"]))
_RX_DIA = re.compile(r"^\d{2}- (" + "|".join(R.DIAS) + r")$", re.I)
# ==========================================================================
# 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()
self.est = Estructura(self.con, self.raiz, self.dicc)
# Las carpetas que ya son de la estructura salen de las plantillas de este tipo de cliente, no de
# una lista fija: «Exportados» es canónica en la raíz de un cliente interno y no lo es en la de
# uno de contenido, donde lo canónico es «_Exportados» dentro del mes.
pl = PLANTILLAS.get(self.tipo, PLANTILLAS["contenido"])
self.primeros, self.en_mes = _propias(self.tipo)
self.por_mes = {c for c, p in pl.items() if "{mes}" in p} # lo que en este tipo de cliente va dentro del mes
self.cajon = 0 # cuántos cajones hay abiertos por encima de donde se está mirando
# Abrir una carpeta porque guarda meses dentro solo tiene sentido donde las piezas se ordenan por
# mes. En un cliente de campañas o de un proyecto puntual, «Campaña X\2025\Noviembre» es una
# campaña con sus fechas, no un cajón: se mueve entera.
self.abre_cajones = "{mes}" in (pl.get("PIEZA") or "")
# dónde vive el material ya ordenado: «{anio}/{mes}/00_Grabacion», «Material/Grabaciones/{anio}/{mes}»…
segs = (pl.get("GRABACION") or "").split("/")
while segs and segs[-1] in ("{dia}", "{sub}", "{fuente}", "{nombre}"):
segs.pop()
self.patron_grab = segs if "{mes}" in segs and "{anio}" in segs else []
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 de rodaje («13- Jueves»).
Hasta la 3.5.0 se omitía cuando ese mes solo tenía un día de rodaje. Munka, 14-oct-2026: las
grabaciones van «siempre según la cámara, organizado por fecha y por carpetas», así que la carpeta
del día va siempre, y dentro una por fuente (URSA_BRAW, SONY, DJI_MIC, TASCAM…)."""
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()
def es_propia(self, nombre, nivel):
return _es_propia(nombre, nivel, self.primeros, self.en_mes)
def pieza_en(self, carpeta_mes, titulo, salvo=None):
"""La pieza con ese título que ya hay en un mes («02- Asimetría» para «Asimetría»), o None.
Así el material de una pieza que estaba en otro sitio se junta con ella en vez de abrir una
carpeta gemela al lado."""
t = R.norm(titulo)
if not t:
return None
def rango(ruta):
# cuál de dos gemelas se queda: la que ya tiene el nombre de la casa, luego la de número más bajo
nombre = os.path.basename(ruta)
p = R.detectar_pieza(nombre)
canonica = bool(p) and nombre == R.nombre_pieza(p[0], limpiar_nombre(p[1]))
return (0 if canonica else 1, p[0] if p else 99, nombre.lower())
mejor = None
for h in self.est.hijos(carpeta_mes):
if salvo and os.path.normcase(h) == os.path.normcase(salvo):
continue
nombre = os.path.basename(h)
p = R.detectar_pieza(nombre)
if R.norm(p[1] if p else nombre) == t and os.path.isdir(h) and (mejor is None or rango(h) < rango(mejor)):
mejor = h
# si las dos están en la misma carpeta, solo se mueve una: la otra es la que se queda y no debe
# proponer a su vez irse con la primera
if mejor and salvo and os.path.normcase(os.path.dirname(mejor)) == os.path.normcase(os.path.dirname(salvo)) \
and rango(salvo) < rango(mejor):
return None
return mejor
# ---- niveles ----
def correr(self):
esc = self.con.execute("SELECT * FROM escaneos WHERE raiz = ?", (self.raiz,)).fetchone()
if esc and (esc["reglas"] or 0) < R.VERSION_REGLAS:
self.avisos.append("El inventario de esta carpeta se hizo con una versión anterior del programa, que "
"reconocía peor las cámaras y las fechas. Escanéala otra vez antes de aplicar nada.")
self.plan_dir(self.raiz, "raiz", None, None)
self.plan_auditoria()
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}), y dos archivos que se "
f"llaman igual no caben en la misma carpeta. La segunda se mueve; la primera queda sin "
f"marcar para que decidas qué versión se queda.")
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, mes_destino=None):
"""`mes_destino` es la carpeta de mes a la que va a parar `ruta` cuando ella misma se mueve (un mes
suelto que entra en su año): sirve para juntar sus piezas con las que ya haya allí."""
dirs, files = self.entradas(ruta)
# numeración de piezas dentro de un mes
num = {"max": 0}
if nivel == "mes":
numeros = defaultdict(list)
for e in dirs:
p = R.detectar_pieza(e.name)
if p:
num["max"] = max(num["max"], p[0])
numeros[p[0]].append(e.name)
for n, nombres in sorted(numeros.items()):
if len({R.norm(R.detectar_pieza(x)[1]) for x in nombres}) > 1:
self.avisos.append(f"En «{self.rel(ruta)}» hay {len(nombres)} piezas con el número {n:02d}: "
+ ", ".join(f"«{x}»" for x in nombres)
+ ". La app no las renumera sola: cámbiale el número a una.")
for e in dirs:
self.plan_subdir(e, nivel, anio, mes, ruta, num, mes_destino)
for e in files:
self.plan_archivo(e, nivel, anio, mes, ruta)
def abrir(self, e):
"""Una carpeta que guarda meses o años dentro no es una cosa que mover: es un cajón. Se abre, y
lo de dentro se ordena por su cuenta, sin heredar el mes en el que el cajón estuviera metido."""
if not self.cajon: # un cajón dentro de otro no hace falta contarlo otra vez
dentro = self.est.fechas_dentro(e.path)
nombres = list(dict.fromkeys(os.path.basename(d) for d in dentro))
self.avisos.append(f"«{self.rel(e.path)}» guarda dentro carpetas de otras fechas ("
+ ", ".join(f"«{x}»" for x in nombres[:3]) + ("…" if len(nombres) > 3 else "")
+ "): la app la abre y ordena lo de dentro, cada cosa a su mes.")
self.cajon += 1
try:
self.plan_dir(e.path, "contenedor", None, None)
finally:
self.cajon -= 1
def plan_subdir(self, e, nivel, anio, mes, padre, num, mes_destino=None):
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 nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA):
# la bandeja de entrada: se abre siempre, y lo de dentro se fecha solo
self.cajon += 1
try:
self.plan_dir(e.path, "contenedor", None, None)
finally:
self.cajon -= 1
return
if self.es_propia(nombre, nivel):
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)
# el año, por este orden: el que diga el nombre, el de la mayoría de lo que hay dentro
fa = int(m_anio.group(1)) if m_anio else (self.est.anio_de(e.path) or 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, mes_destino=canon)
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, mes_destino=canon)
return
if nivel == "mes":
p = R.detectar_pieza(nombre)
if p:
cub_t, _ = R.cubeta_carpeta(p[1], self.dicc)
# "03 - Grabacion Agosto": es la sesión del mes, no una pieza. Pero solo si de verdad es
# una tarjeta volcada: una pieza a la que alguien llamó grabación no se desarma.
if cub_t == "GRABACION" and self.est.es_rodaje(e.path, por_nombre=_de_fabrica(p[1], "GRABACION")):
self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación")
return
if cub_t in ("GRABACION", "CONTENEDOR") and self.est.fechas_dentro(e.path):
self.abrir(e) # no es una pieza: es un cajón con cosas de varios meses
return
canon = R.nombre_pieza(p[0], limpiar_nombre(p[1]))
gemela = self.pieza_en(mes_destino or padre, p[1], salvo=e.path)
if gemela and os.path.basename(gemela) != nombre:
self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar",
f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media")
elif 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)
if cub_t != "PIEZA" and self.est.fechas_dentro(e.path):
self.avisos.append(f"«{self.rel(e.path)}» está numerada como una pieza, pero dentro guarda carpetas "
f"de varias fechas. Si es un cajón con cosas de distintos meses, dilo en «Por "
f"nombrar» y la app lo abrirá para ordenar lo de dentro.")
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)
if self.est.fechas_dentro(e.path):
# trae sus propios meses o años: lo de dentro se fecha solo, sin heredar el mes de fuera
self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else None, None)
else:
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
montada = ""
if cub == "GRABACION":
if self.est.es_rodaje(e.path, por_nombre=_de_fabrica(nombre, "GRABACION")):
self.plan_grabacion(e, nivel, anio, mes, motivo)
return
# se llama (o alguien dijo que era) una grabación, pero dentro hay proyectos, exportados o
# portadas: desarmarla por cámara y por mes deshace un trabajo montado
partes = self.est.partes(e.path)
montada = ("tiene nombre de grabación, pero dentro hay algo montado ("
+ ", ".join(sorted(_PARTE_FRASE.get(x, x.lower()) for x in partes)) + "): no se desarma"
if len(partes) >= 2 else
"tiene nombre de grabación, pero casi nada de lo que hay dentro es material de cámara: no se reparte")
cub = None
# La forma manda sobre el nombre: una carpeta con meses o años dentro se abre, aunque se llame
# «Proyectos» o «Edición». Vale para lo que no tiene sitio propio y para lo que en este tipo de
# cliente va dentro de cada mes (exportados, portadas…); la música o el stock no van por fecha.
if self.abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in self.por_mes) \
and self.est.fechas_dentro(e.path) and (montada or cub is not None or not self.est.es_rodaje(e.path)):
self.abrir(e)
return
generico = nn in self.dicc and not montada
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:
if not montada and self.est.es_rodaje(e.path):
self.plan_grabacion(e, nivel, anio, mes, "carpeta sin nombre reconocido pero llena de clips de cámara")
return
if nivel == "mes":
gemela = self.pieza_en(mes_destino or padre, limpio, salvo=e.path)
if gemela:
self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar",
f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media")
return
num["max"] += 1
canon = R.nombre_pieza(num["max"], 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 = montada or "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 = self.abs_(destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path)))
if cub == "PIEZA":
gemela = self.pieza_en(os.path.dirname(destino), limpio, salvo=e.path)
if gemela:
destino = gemela
motivo += f" · la misma pieza ya está en ese mes: se junta con «{os.path.basename(gemela)}»"
self.agregar(e.path, 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)
# solo los nombres de fábrica («Grabación», «URSA») son genéricos: «Videos Maquinas», aunque
# alguien haya confirmado que es una grabación, sigue siendo el nombre de esa sesión
generico = (nn in R.DICCIONARIO_SEMILLA or nn in ("grabacion", "grabado", "grabaciones")
or R.es_carpeta_de_tarjeta(nombre)) # «DCIM», «PRIVATE»: la tarjeta tal cual
filas = archivos_bajo(self.con, e.path)
if not filas:
return
etiqueta_base = "" if generico else R.etiqueta_grabacion(nombre)
# Subcarpetas que no son rodaje (los exportados de esa sesión, su música, un proyecto): cada una
# va a lo suyo en vez de acabar revuelta en 00_Grabacion\OTROS.
aparte = []
subdirs, _ = self.entradas(e.path)
for d in subdirs:
cub_d, _m = R.cubeta_carpeta(d.name, self.dicc)
if cub_d in R.PARTES_PIEZA and self.est.utiles(d.path):
self.plan_subdir(d, "contenedor", anio, mes, e.path, {"max": 0})
aparte.append(os.path.normcase(d.path) + os.sep)
grupos = defaultdict(list)
desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
aqui = os.path.normcase(os.path.normpath(e.path))
for f in filas:
if f["es_basura"] or f["es_proxy"]:
continue
if aparte and (os.path.normcase(f["dir"]) + os.sep).startswith(tuple(aparte)):
continue
if f["rol"] not in R.ROLES_RODAJE:
# un proyecto, un guion o una imagen de IA que estaban de paso: van donde iría cualquier
# archivo suelto de su clase, con el mes de esta carpeta
self.plan_fila(f, "contenedor", anio, mes)
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 ("imagen", "foto_camara", "celular_foto"):
fuente = "FOTOS"
else:
fuente = "OTROS"
# lo que digan las carpetas intermedias: el año o el mes («Blackmagic (BRAW)/2023/…»), el
# nombre de la sesión («Grabaciones/Tomas consultorio/…») y si ya estaba apartado por repetido
anio_ruta, mes_ruta, etiqueta, repetido = None, None, etiqueta_base, False
segs = [] if os.path.normcase(os.path.normpath(f["dir"])) == aqui else os.path.relpath(f["dir"], e.path).split(os.sep)
for seg in segs:
if R.detectar_anio(seg):
anio_ruta = R.detectar_anio(seg)
elif R.detectar_mes(seg)[0]:
mes_ruta = R.detectar_mes(seg)[0]
elif R.norm(seg) == R.norm(R.DUPLICADOS):
repetido = True
elif not etiqueta and R.norm(seg) not in R.DICCIONARIO_SEMILLA and not R.es_carpeta_de_tarjeta(seg):
etiqueta = R.etiqueta_grabacion(seg)
fa, fm = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado»
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. Y el año también es el del
# archivo: antes se deducía del año de la carpeta, y unos clips de diciembre de 2025
# metidos en «2025\03- Marzo» salían hacia diciembre de 2024.
ay, am, fconf = anio, mes, "alta"
if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
ay, am, fconf = fa, fm, "media"
desacuerdo[(fa, fm)] += 1
else:
ay, am = fa, fm
# la fecha de un original de cámara es la del rodaje aunque no esté en el nombre
fconf = "media" if por_nombre or _fecha_de_camara(f) 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 and not (por_nombre and _discrepa_mes(f["fecha"], f["fecha_origen"], anio_ruta or anio or fa, 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), etiqueta, fuente, repetido, fconf)].append((f["ruta"], f["bytes"]))
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, etiqueta, fuente, repetido, fconf), archivos in grupos.items():
sub = os.path.join(*[x for x in (etiqueta, fuente, R.DUPLICADOS if repetido else "") if x])
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 de rodaje"
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 fila:
self.plan_fila(fila, nivel, anio, mes)
def plan_fila(self, fila, nivel, anio, mes):
"""Un archivo suelto: a dónde va por lo que es."""
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):
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" and not _fecha_de_camara(fila):
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_auditoria(self):
"""Lo que ya está en su carpeta de grabación, ¿está en el mes en que se grabó?
Las carpetas de la estructura no se vuelven a ordenar, así que lo que una versión anterior (o una
persona) dejó en el mes equivocado se quedaba ahí para siempre. Medido el 4-oct-2026 sobre el
material real: 160 clips de diciembre de 2025 (429 GB) vivían en la 00_Grabacion de marzo de 2025, y
había material de marzo en octubre, de junio en septiembre y de septiembre en noviembre. Aquí se
repasa solo lo que tiene la fecha en el nombre, que es lo que no admite discusión."""
pat = self.patron_grab
if not pat:
return
ia, im = pat.index("{anio}"), pat.index("{mes}")
roles = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video", "foto_camara")
por_mes = defaultdict(list) # (año, mes) de la carpeta -> [(archivo, lo que cuelga por debajo)]
for f in self.con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN (%s)"
% ",".join("?" * len(roles)), (self.raiz,) + roles).fetchall():
rel = os.path.relpath(f["dir"], self.raiz)
segs = [] if rel == "." else rel.split(os.sep)
if len(segs) < len(pat):
continue
for s, p in zip(segs, pat):
if not (R.detectar_anio(s) if p == "{anio}" else R.detectar_mes(s)[0] if p == "{mes}" else R.norm(s) == R.norm(p)):
break
else:
resto = segs[len(pat):]
tenia_dia = bool(resto and _RX_DIA.match(resto[0]))
if tenia_dia:
resto = resto[1:] # la carpeta del día se vuelve a calcular en destino
por_mes[(R.detectar_anio(segs[ia]), R.detectar_mes(segs[im])[0])].append((f, resto, tenia_dia))
grupos = defaultdict(list)
grupos_dia = defaultdict(list) # ya en su mes, pero sin carpeta de día o con la grabadora en «AUDIO»
for (anio_c, mes_c), fs in por_mes.items():
fuera = [(f, resto) for f, resto, _ in fs if _discrepa_mes(f["fecha"], f["fecha_origen"], anio_c, mes_c)]
if fuera:
# lo que solo tiene fecha de modificación pero es del mismo día que lo que se va (la Sony que
# rodó junto a la URSA) se va con ello: si no, el rodaje quedaría partido entre dos meses
dias = {f["fecha"] for f, _ in fuera}
fuera += [(f, resto) for f, resto, _ in fs
if f["fecha_origen"] == "mtime" and f["fecha"] in dias and _fecha_de_camara(f)]
for f, resto in fuera:
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am,
dia=self.dia_de(f["fecha"], ay, am), sub=os.sep.join(resto))
grupos[(f["dir"], destino, anio_c, mes_c, ay, am)].append((f["ruta"], f["bytes"], f["fecha_origen"] == "nombre"))
# Lo que sí está en su mes: desde la 3.6.0 las grabaciones van siempre dentro de su día de
# rodaje y cada grabadora en su carpeta (DJI_MIC, TASCAM, ZOOM), así que lo ordenado con
# versiones anteriores (sin carpeta de día, o con la grabadora en «AUDIO») se vuelve a mirar.
# Solo lo que tiene fecha de fiar (en el nombre, o un original de cámara, cuya fecha de
# modificación es la del rodaje): lo demás se queda donde está.
idos = {f["ruta"] for f, _ in fuera}
for f, resto, tenia_dia in fs:
if f["ruta"] in idos or (f["fecha_origen"] == "mtime" and not _fecha_de_camara(f)):
continue
nuevo = list(resto)
fu = R.FUENTE_CARPETA.get(f["fuente"])
if fu and fu != "AUDIO" and f["rol"] == "audio_grabado":
for i in range(len(nuevo)):
if R.norm(nuevo[i]) == "audio":
nuevo[i] = fu
if tenia_dia and nuevo == resto:
continue
destino = destino_plantilla(self.tipo, "GRABACION", anio=anio_c, mes=mes_c,
dia=self.dia_de(f["fecha"], anio_c, mes_c), sub=os.sep.join(nuevo))
if self.abs_(destino).lower() == os.path.normpath(f["dir"]).lower():
continue
grupos_dia[(f["dir"], destino, not tenia_dia, nuevo != resto)].append((f["ruta"], f["bytes"]))
for (origen, destino, sin_dia, grabadora), archivos in grupos_dia.items():
partes = (["sin la carpeta del día de rodaje"] if sin_dia else []) + (["la grabadora va en su propia carpeta, no en «AUDIO»"] if grabadora else [])
self.agregar(origen, self.abs_(destino), "GRABACION", "mover archivos",
"ya en su mes, pero " + " y ".join(partes), "media", archivos=archivos)
n = b = 0
for (origen, destino, anio_c, mes_c, ay, am), archivos in grupos.items():
n += len(archivos)
b += sum(a[1] for a in archivos)
self.agregar(origen, self.abs_(destino), "GRABACION", "mover archivos",
f"ya estaba ordenado, pero en {R.nombre_mes(mes_c)} de {anio_c}: se grabó en {R.nombre_mes(am)} de {ay}",
"alta" if all(a[2] for a in archivos) else "media",
archivos=[(a[0], a[1]) for a in archivos])
if n:
self.avisos.append(f"{_plural(n, 'clip ya ordenado estaba', 'clips ya ordenados estaban')} en un mes que no es el "
f"de su rodaje ({_gb(b)}). El plan los lleva al mes en que se grabaron, que es lo que dice "
f"el nombre que les puso la cámara.")
def plan_basura(self):
con = self.con
# «_Papelera» era el nombre de antes. Las carpetas ordenadas con versiones viejas tienen las dos, y
# lo apartado quedaba repartido entre una y otra: se junta todo en la de ahora.
vieja = os.path.join(self.raiz, R.PAPELERAS[1])
if os.path.isdir(vieja):
self.agregar(vieja, self.abs_(R.PAPELERA), "BASURA", "papelera",
f"la papelera con el nombre antiguo se junta con «{R.PAPELERA}»: era lo mismo en dos sitios", "alta")
# 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))
# hechas/total cuentan filas del plan (es lo que miran «en marcha» y la barra de tareas); unidades cuenta
# archivo a archivo dentro de las filas de archivos sueltos, para que la barra avance suave en una fila de
# 400 clips; actual/archivo dicen qué se está moviendo; lote/fin, qué tanda fue y cuándo acabó.
APLICANDO = {"hechas": 0, "total": 0, "actual": "", "archivo": "", "unidades": 0, "unidades_total": 0,
"movidos": 0, "fallos": 0, "lote": "", "inicio": 0, "fin": 0}
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()))
unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas)
fallos = 0
APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0,
unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0)
for f in filas:
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "", archivo="")
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"]:
APLICANDO.update(unidades=APLICANDO["unidades"] + 1, archivo=os.path.basename(a))
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}")
APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila)
fallos += fallos_fila
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:
fallos += 1
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
fallos += 1
errores.append(f"{f.get('origen_rel')}: {ex}")
APLICANDO.update(unidades=APLICANDO["unidades"] + 1, movidos=len(movidos), fallos=fallos)
# terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
APLICANDO.update(hechas=len(filas), actual="", archivo="", unidades=unidades_total, movidos=len(movidos),
fallos=fallos, fin=time.time())
# 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()
# el plan que hubiera en memoria ya no describe la carpeta: se olvida y se vuelve a pensar solo
for k in {r for de, a in movidos for r in (_raiz_de(de), _raiz_de(a)) if r}:
olvidar_plan(k)
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()):
if R.norm(os.path.basename(ruta)) == R.norm(R.ENTRADA):
break # la bandeja de entrada se queda aunque esté vacía
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 _pieza_nueva(ruta, desde):
"""True si la carpeta es una pieza creada después de `desde`, o el mes o el año que la contienen."""
try:
st = os.stat(ruta)
except OSError:
return False
if (getattr(st, "st_birthtime", None) or st.st_ctime) <= desde:
return False
nombre = os.path.basename(ruta)
if R.detectar_pieza(nombre):
return True
if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]:
try:
return any(_pieza_nueva(os.path.join(ruta, x), desde) for x in os.listdir(ruta))
except OSError:
return False
return False
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 = [], []
reciente = time.time() - 30 * 86400
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 c.lower() == os.path.join(raiz, R.ENTRADA).lower():
continue # la bandeja de entrada está vacía a propósito
if not os.path.isdir(c) or not _sin_archivos(c):
continue # ya no existe, o alguien dejó algo dentro
# una pieza recién creada tiene sus subcarpetas vacías porque aún no se ha trabajado en ella: no
# es un hueco que dejó el orden. Lo mismo el mes o el año que se crearon para ella.
if _pieza_nueva(c, reciente):
tomadas.append(c.lower())
continue
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()
if borradas:
olvidar_plan(raiz)
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", "mkdir")]
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, quitadas = 0, [], [], [], []
for e in reversed(entradas):
try:
if e["op"] == "mkdir":
# una carpeta que creó la app (una pieza nueva): se quita solo si sigue vacía
if os.path.isdir(e["de"]):
if os.listdir(e["de"]):
errores.append(f"{os.path.basename(e['de'])}: ya tiene cosas dentro; se deja")
else:
os.rmdir(e["de"])
quitadas.append(e["de"])
deshechos += 1
elif 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 or quitadas) 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)))
for d in quitadas:
con.execute("DELETE FROM carpetas WHERE ruta = ?", (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):
"""Las carpetas sobre las que hay algo que decidir, con lo que la app cree que es cada una.
Recorre el inventario igual que el plan recorre el disco y se queda solo con lo que el plan llega a
ver: no entra en las carpetas de la propia app (_Para borrar, 00_Grabacion…), ni dentro de las piezas,
ni en lo que ya sabe ordenar solo (una carpeta llena de clips de cámara, un cajón con meses dentro).
Hasta la 3.1.1 salían todas las carpetas del inventario, y eso preguntaba cosas que no cambiaban nada
o que no eran del cliente. Medido el 4-oct-2026 sobre el material real: de 6 preguntas, 6 sobraban
—«basura» era la _Para borrar\\basura de la app, «Orden» sus propias etiquetas dentro de 00_Grabacion,
y el resto carpetas que solo existían ya dentro de la papelera—, y contestarlas enseñó al diccionario
que «orden», «videos» y «basura» eran grabaciones."""
raiz = os.path.normpath(raiz)
tipo = (raiz_cfg(raiz) or {}).get("tipo", "contenido")
dicc = DICC["carpetas"]
con = db()
est = Estructura(con, raiz, dicc)
resumen = _resumen_carpetas(con, raiz)
con.close()
primeros, en_mes = _propias(tipo)
pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"])
por_mes = {c for c, p in pl.items() if "{mes}" in p}
abre_cajones = "{mes}" in (pl.get("PIEZA") or "")
tokens = {}
def apunta(ruta, titulo, cub, motivo, nota=""):
tok = R.norm(titulo)
if not tok or len(tok) > 60:
return
c = est.carp.get(os.path.normcase(ruta))
t = tokens.get(tok)
if t is None:
t = tokens[tok] = {"token": tok, "ejemplo": os.path.basename(ruta), "n": 0, "archivos": 0, "bytes": 0,
"rutas": [], "dentro_pieza": False, "cubeta": cub or "", "motivo": motivo, "nota": nota,
"aprendido": tok in dicc and tok not in R.DICCIONARIO_SEMILLA}
t["n"] += 1
t["archivos"] += (c["n"] if c else 0) or 0
t["bytes"] += (c["bytes"] if c else 0) or 0
if len(t["rutas"]) < 4:
t["rutas"].append(os.path.relpath(ruta, raiz))
def baja(ruta, nivel, anio):
for h in sorted(est.hijos(ruta), key=str.lower):
nombre = os.path.basename(h)
if nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA):
baja(h, "contenedor", None) # la bandeja de entrada: el plan la abre siempre
continue
if _es_propia(nombre, nivel, primeros, en_mes) or R.es_basura_dir(nombre) or R.es_proxy_dir(nombre):
continue
if not est.utiles(h):
continue # vacía: nada que decidir (se ofrece en «Carpetas vacías»)
a = R.detectar_anio(nombre)
if a and nivel in ("raiz", "contenedor"):
baja(h, "anio" if nivel == "raiz" else "contenedor", a)
continue
if nivel == "anio" and R.detectar_mes(nombre)[0]:
baja(h, "mes", anio)
continue
if nivel in ("raiz", "contenedor") and R.detectar_mes(re.sub(r"20\d\d", " ", nombre).strip(" -_"))[0]:
baja(h, "mes", anio)
continue
if nivel == "mes":
p = R.detectar_pieza(nombre)
if p:
cub_t, mot = R.cubeta_carpeta(p[1], dicc)
if cub_t == "GRABACION" and est.es_rodaje(h, por_nombre=_de_fabrica(p[1], "GRABACION")):
continue # la sesión del mes: el plan la reparte sola
if cub_t in ("GRABACION", "CONTENEDOR") and est.fechas_dentro(h):
apunta(h, p[1], "CONTENEDOR", mot)
baja(h, "contenedor", None)
elif cub_t != "PIEZA" and est.fechas_dentro(h):
apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas")
continue # una pieza: no se toca por dentro
cub, mot = R.cubeta_carpeta(nombre, dicc)
if cub == "IGNORAR":
apunta(h, nombre, cub, mot)
continue
if cub == "CONTENEDOR":
apunta(h, nombre, cub, mot)
m_anio = re.search(r"(20\d\d)", nombre)
baja(h, "contenedor", int(m_anio.group(1)) if m_anio else (None if est.fechas_dentro(h) else anio))
continue
montada = False
if cub == "GRABACION":
if est.es_rodaje(h, por_nombre=_de_fabrica(nombre, "GRABACION")):
apunta(h, nombre, cub, mot)
continue
cub, montada = None, True
if abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in por_mes) and est.fechas_dentro(h) \
and (montada or cub is not None or not est.es_rodaje(h)):
baja(h, "contenedor", None) # un cajón con meses dentro: el plan lo abre solo
continue
if cub is None:
if not montada and est.es_rodaje(h):
continue # llena de clips de cámara: el plan la reparte sola
if nivel == "mes":
continue # dentro de un mes, lo que no se reconoce es una pieza
apunta(h, nombre, "", "", "tiene nombre de grabación, pero no es una tarjeta volcada" if montada else "")
else:
apunta(h, nombre, cub, mot)
baja(raiz, "raiz", None)
for t in tokens.values():
if not t["cubeta"]:
_sugerir(est, resumen, raiz, t)
return sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
_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
_PARTE_FRASE = {"EXPORTADOS": "exportados", "PORTADAS": "portada", "STOCK": "stock", "IA": "IA", "GUION": "guion",
"PROYECTOS": "proyectos", "MUSICA": "música", "SFX": "sonidos"}
def _sugerir(est, resumen, raiz, t):
"""Qué es probablemente esta carpeta: por su forma, por lo que tiene dentro y por su nombre.
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible).
Lo de dentro se cuenta por número de archivos, no por peso: los clips de cámara pesan cien veces más
que todo lo demás, así que por peso casi cualquier carpeta grande «era» una grabación, y aceptar esa
propuesta la mandaba a desarmar. Una grabación de verdad el plan ya la reconoce solo; aquí nunca se
propone."""
import difflib
rutas = [os.path.join(raiz, rel) for rel in t["rutas"][:3]]
conteo = defaultdict(lambda: [0, 0])
total = [0, 0]
for r in rutas:
for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(r)), {}).items():
conteo[k][0] += n
conteo[k][1] += b
total[0] += n
total[1] += b
orden = sorted(conteo.items(), key=lambda kv: -kv[1][0])
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 = "", "", ""
fechas = list(dict.fromkeys(os.path.basename(d) for r in rutas for d in est.fechas_dentro(r)))
partes = set()
for r in rutas:
partes |= est.partes(r)
if fechas:
# a) por la forma: guarda meses o años dentro
sug, conf = "CONTENEDOR", "alta" if len(fechas) >= 2 else "media"
por_que = ("dentro guarda carpetas de otras fechas (" + ", ".join(f"«{x}»" for x in fechas[:3])
+ ("…" if len(fechas) > 3 else "") + "): es un cajón que hay que abrir, no una cosa que mover entera")
elif len(partes) >= 2:
# b) por la forma: trae las carpetas de una pieza montada
sug, conf = "PIEZA", "alta" if len(partes) >= 3 else "media"
por_que = ("dentro tiene carpetas de " + ", ".join(sorted(_PARTE_FRASE.get(p, p.lower()) for p in partes))
+ ": es una pieza montada, se mueve entera")
else:
# c) 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", "GRABACION"):
sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»"
# d) por el contenido (manda si es contundente)
if total[0] >= 3 and orden:
k, (n, b) = orden[0]
frac = n / total[0]
if frac >= 0.75:
por_cont = {"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 bastantes archivos.
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"],
"por_comparar": dd["por_comparar"], "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)"
lee_braw = bool(decodificador_braw().get("ok"))
# los BRAW que solo leyó ffprobe se miden otra vez con el decodificador: se les nota en que no tienen
# «perfil», que es donde queda apuntada su curva
remedir = " OR (ext = '.braw' AND analizado = 1 AND COALESCE(perfil, '') = '')" if lee_braw else ""
pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND (analizado IN {estados}{remedir}) 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
m = medir_braw(f["ruta"]) if lee_braw else {}
if m.get("ok"):
info.update(ancho=m["ancho"], alto=m["alto"], fps=m["fps"] or info["fps"], dur=m["dur"] or info["dur"], perfil=_curva(m))
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))
# Desde cuántas veces la tasa objetivo compensa recomprimir un clip que ya viene comprimido entre cuadros.
# H.265, AV1 y VP9 rinden como la salida: de ahí solo se baja quitando calidad, y hace falta que el origen
# vaya muy holgado (el doble). Un H.264 necesita unas 1,6 veces los bits de H.265 para verse igual: basta
# con que no esté ya por debajo del objetivo. Medido el 5-oct-2026 con clips de V:\Galeano contra su original:
# un H.264 4.6K de 33 Mb/s (1,08 veces el objetivo) queda en la mitad con nota 96,7 sobre 100; uno de
# 121 Mb/s, en la quinta parte. Lo que guarda cada cuadro entero (XAVC-I, ProRes, BRAW) no pasa por aquí.
RECOMPRIMIR_DESDE = {"hevc": 2.0, "av1": 2.0, "vp9": 2.0, "h264": 1.0, "mpeg4": 0.7, "mpeg2video": 0.5}
NOMBRE_CODEC = {"hevc": "H.265", "av1": "AV1", "vp9": "VP9", "h264": "H.264", "mpeg4": "MPEG-4", "mpeg2video": "MPEG-2"}
def poco_que_ganar(f, objetivo):
"""True si el clip ya viene comprimido y tan cerca de lo que pesaría convertido que no compensa tocarlo."""
if not f["kbps"] or f["codec"] not in RECOMPRIMIR_DESDE or "intra" in (f["perfil"] or "").lower():
return False
return f["kbps"] <= RECOMPRIMIR_DESDE[f["codec"]] * objetivo
def _salio_del_convertidor(f, salidas):
"""Lleva el sufijo del convertidor y además está en el códec que ese sufijo produce: un «Entrevista_HQ.mp4»
en H.264, o un «clip_proxy.mp4» de la cámara, no los hizo este programa."""
if os.path.normcase(os.path.normpath(f["ruta"])) in salidas:
return True
if not R.es_conversion(f["nombre"]):
return False
base = os.path.splitext(f["nombre"])[0].lower()
sufijo = next((s for s in R.SUFIJOS_CONVERSION if base.endswith(s)), "")
return f["codec"] == {"_h264": "h264", "_proxy": "prores", "_lt": "prores", "_hq": "prores"}.get(sufijo, "hevc")
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"])
lee_braw = bool(decodificador_braw().get("ok"))
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()}
# lo que ya salió del convertidor no vuelve a entrar. Medido el 4-oct-2026 sobre el material real: 22
# clips estaban convertidos dos veces y algunos tres («…_10bit_10bit_10bit.mp4»), cada pasada con su
# pérdida y sus horas, porque una conversión con buen bitrate no pasaba por «ya está en HEVC eficiente».
salidas = {os.path.normcase(os.path.normpath(r["salida"])) for r in con.execute(
"SELECT salida FROM trabajos WHERE estado IN ('listo','verificado','reemplazado') AND salida IS NOT NULL").fetchall()}
# y lo que ya se probó con esta misma calidad y no compensó, tampoco
probados = {r["ruta"]: r["log"] or "" for r in con.execute(
"SELECT ruta, log FROM trabajos WHERE formato = ? AND perfil = ? AND estado = 'descartado'", (formato, perfil)).fetchall()}
con.close()
out = []
for f in filas:
motivo_no = ""
# los BRAW los lee el decodificador de Blackmagic; solo si falta se intenta por el guion de Resolve
motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg"
obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
if _salio_del_convertidor(f, salidas):
motivo_no = "ya es una conversión hecha con este programa"
elif 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 f["ruta"] in probados:
motivo_no = "ya se probó con esta calidad: " + probados[f["ruta"]].replace("no compensa: ", "").split(". ")[0]
elif fm["codec"] == "hevc" and poco_que_ganar(f, obj):
motivo_no = (f"ya viene comprimido ({NOMBRE_CODEC.get(f['codec'], f['codec'])} a {f['kbps'] / 1000:.0f} Mb/s): "
"recomprimirlo ahorraría poco y le quitaría calidad")
elif motor == "resolve" and formato != "h265_10bit":
motivo_no = "sin el decodificador de Blackmagic 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)"
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]
lee_braw = bool(decodificador_braw().get("ok"))
con = db()
ids = []
lote, movidos, errores, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
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 = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg"
if motor == "resolve" and formato != "h265_10bit":
continue # por el guion de Resolve los BRAW solo salen a H.265 10 bits
salida = salida_para(r, raiz, formato)
if os.path.exists(salida):
previo = con.execute("SELECT * FROM trabajos WHERE ruta = ? AND salida = ? AND estado = 'descartado' ORDER BY id DESC",
(r, salida)).fetchone()
if previo:
try:
if _apartar_convertido(dict(previo), lote, movidos):
raiz_desc = raiz_desc or previo["raiz"]
except OSError as ex:
errores.append(f"{os.path.basename(salida)}: {ex}")
# 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))
# los intentos fallidos de este mismo clip se sustituyen por el nuevo (el 5-oct-2026 había 174
# filas de error para 87 clips: cada tanda dejaba las suyas)
con.execute("DELETE FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('error','cancelado','descartado')", (r, formato))
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()
_cerrar_lote(lote, raiz_desc or raiz, movidos, errores)
return ids
# empezo: cuándo arrancó el clip que va; ritmo: (segundos de clip, segundos reales) de cada clip acabado en
# esta tanda, para decir cuánto falta
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": [],
"esperando": "", "hechos_ids": [], "ultima": None}
def _opt(k, defecto=None):
v = (CONFIG.get("optimizar") or {}).get(k)
return defecto if v is None else v
def en_horario(ahora=None):
"""Si la cola tiene horario, ¿estamos dentro? (True, "") o (False, "hasta las 20:00"). El horario puede
pasar por medianoche (20:00 a 07:00)."""
h = _opt("horario") or {}
if not h.get("activo"):
return True, ""
try:
d1, m1 = map(int, (h.get("desde") or "20:00").split(":"))
d2, m2 = map(int, (h.get("hasta") or "07:00").split(":"))
except ValueError:
return True, ""
t = time.localtime(ahora or time.time())
x, a, b = t.tm_hour * 60 + t.tm_min, d1 * 60 + m1, d2 * 60 + m2
dentro = (a <= x < b) if a < b else (x >= a or x < b)
return (True, "") if dentro else (False, f"hasta las {h.get('desde') or '20:00'}")
APPS_EDICION = {"resolve.exe": "DaVinci Resolve", "adobe premiere pro.exe": "Premiere Pro", "afterfx.exe": "After Effects"}
def editando():
"""Qué programa de edición está usando la tarjeta ahora mismo, o "". Lo dice nvidia-smi pmon (uso de
cómputo, codificador y decodificador por proceso); si no hay nvidia-smi, basta con que el programa esté
abierto. Solo cuenta si está activado optimizar.pausar_si_edito."""
if not _opt("pausar_si_edito", True):
return ""
try:
r = subprocess.run(["nvidia-smi", "pmon", "-c", "1", "-s", "u"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
if r.returncode == 0 and r.stdout:
for linea in r.stdout.splitlines():
partes = linea.split(None, 9)
if linea.startswith("#") or len(partes) < 10:
continue
nombre = partes[9].strip().lower()
uso = [float(u) for u in partes[3:7] if u.replace(".", "", 1).isdigit()]
for exe, bonito in APPS_EDICION.items():
if nombre and exe.startswith(nombre[:10]) and any(u > 5 for u in uso): # pmon recorta el nombre
return bonito
return ""
except (OSError, subprocess.SubprocessError, ValueError):
pass
try:
r = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
bajo = (r.stdout or "").lower()
for exe, bonito in APPS_EDICION.items():
if f'"{exe}"' in bajo:
return bonito
except (OSError, subprocess.SubprocessError):
pass
return ""
def _esperar_turno():
"""Antes de cada clip: si hay horario y estamos fuera, o si un programa de edición está usando la
tarjeta, la cola espera (sigue activa) y lo dice. Nunca corta un clip a medias. False si la detuvieron."""
while COLA["activa"] and not COLA["cancelar"]:
dentro, hasta = en_horario()
motivo = "" if dentro else f"espera {hasta}, por el horario de la cola"
if not motivo:
app = editando()
motivo = f"espera a que {app} deje la tarjeta" if app else ""
COLA["esperando"] = motivo
if not motivo:
return True
COLA["mensaje"] = motivo
time.sleep(20)
COLA["esperando"] = ""
return False
def notificar(titulo, texto):
"""Un aviso de Windows (la burbuja de la esquina), con la identidad del programa. Va en un PowerShell
aparte y sin esperar: si falla, no pasa nada."""
if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"):
return False
esc_ = lambda s: str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("'", "&#39;")
ps = ("[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n"
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null\n"
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument\n"
f"$xml.LoadXml('<toast><visual><binding template=\"ToastGeneric\"><text>{esc_(titulo)}</text><text>{esc_(texto)}</text></binding></visual></toast>')\n"
"$t = New-Object Windows.UI.Notifications.ToastNotification $xml\n"
"try { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Labstream.OrganiLAB').Show($t) }\n"
"catch { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($t) }\n")
try:
ruta = os.path.join(DATOS, "aviso.ps1")
with open(ruta, "w", encoding="utf-8-sig") as fh:
fh.write(ps.replace("\\n", "\n"))
subprocess.Popen(["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ruta],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=SIN_VENTANA)
return True
except OSError:
return False
def _resumen_tanda():
"""Al acabar la cola: qué se hizo en esta tanda (clips, pesos, tiempo, nota media, fallos, por carpeta).
Queda en la bitácora (op «tanda»), se recuerda para la interfaz y, si está activado, se avisa."""
ids = list(COLA.get("hechos_ids") or [])
if not ids:
return None
con = db()
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
f"WHERE t.id IN ({','.join('?' * len(ids))})", ids).fetchall()]
con.close()
bien = [t for t in ts if t["estado"] in ("listo", "verificado", "reemplazado")]
notas = [t["nota"] for t in bien if t.get("nota")]
carpetas = {}
for t in bien:
try:
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
except ValueError:
rel = os.path.dirname(t["ruta"])
c = carpetas.setdefault(rel, {"carpeta": rel, "clips": 0, "bytes_in": 0, "bytes_out": 0})
c["clips"] += 1
c["bytes_in"] += t["bytes_in"] or 0
c["bytes_out"] += t["bytes_out"] or 0
raices = sorted({t["raiz"] for t in ts if t.get("raiz")})
seg = round(time.time() - (COLA["inicio"] or time.time()))
r = {"lote": "tanda-" + time.strftime("%Y%m%d-%H%M%S"), "inicio": COLA["inicio"], "seg": seg, "clips": len(bien), "pedidos": len(ts),
"bytes_in": sum(t["bytes_in"] or 0 for t in bien), "bytes_out": sum(t["bytes_out"] or 0 for t in bien),
"fallos": sum(1 for t in ts if t["estado"] in ("error", "cancelado")), "descartados": sum(1 for t in ts if t["estado"] == "descartado"),
"reemplazados": sum(1 for t in ts if t["estado"] == "reemplazado"), "verificados": sum(1 for t in ts if t["estado"] == "verificado"),
"nota": round(sum(notas) / len(notas), 1) if notas else None, "nota_min": min(notas) if notas else None,
"raiz": raices[0] if len(raices) == 1 else "", "raices": raices,
"cliente": ", ".join((raiz_cfg(x) or {}).get("cliente") or os.path.basename(x) for x in raices),
"carpetas": sorted(carpetas.values(), key=lambda c: -c["bytes_in"]), "ids": ids}
bitacora({"op": "tanda", **r})
COLA["ultima"] = dict(r, ts=time.time())
if _opt("avisar", True) and (r["clips"] or r["fallos"]):
que = f"{r['clips']} clip{'s' if r['clips'] != 1 else ''} convertido{'s' if r['clips'] != 1 else ''}"
if r["clips"]:
que += f" y verificado{'s' if r['clips'] != 1 else ''} en {_tiempo_llano(seg)}. " + ("Ya reemplazados." if r["reemplazados"] == r["clips"] else "Falta reemplazar.")
if r["fallos"]:
que += f" {r['fallos']} fallaron."
notificar("OrganiLAB terminó" if r["clips"] else "OrganiLAB: la tanda no salió", que)
return r
def _tiempo_llano(seg):
seg = int(seg or 0)
if seg < 60:
return f"{seg} s"
if seg < 3600:
return f"{seg // 60} min"
return f"{seg // 3600} h {round(seg % 3600 / 60)} min"
def _ultima_tanda_guardada():
for e in reversed(leer_bitacora(3000)):
if e.get("op") == "tanda":
return e
return {}
def tanda_csv(lote):
"""Los clips de una tanda, en CSV (para el cliente o para mirar en Excel)."""
import csv, io
e = next((x for x in reversed(leer_bitacora(3000)) if x.get("op") == "tanda" and x.get("lote") == lote), None)
if not e:
return None
ids = e.get("ids") or []
con = db()
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
f"WHERE t.id IN ({','.join('?' * len(ids))}) ORDER BY t.id", ids).fetchall()] if ids else []
con.close()
s = io.StringIO()
w = csv.writer(s, delimiter=";", lineterminator="\n")
w.writerow(["clip", "carpeta", "estado", "antes (MB)", "después (MB)", "ahorro %", "duración (s)", "nota de calidad", "formato", "salida"])
for t in ts:
try:
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
except ValueError:
rel = os.path.dirname(t["ruta"])
bi, bo = t["bytes_in"] or 0, t["bytes_out"] or 0
w.writerow([os.path.basename(t["ruta"]), rel, EST_CSV.get(t["estado"], t["estado"]), round(bi / 1e6, 1), round(bo / 1e6, 1) if bo else "",
round((1 - bo / bi) * 100) if bi and bo else "", round(t["dur"] or 0, 1) if t.get("dur") else "", t.get("nota") or "",
formato_de(t), t["salida"] or ""])
return s.getvalue()
EST_CSV = {"pendiente": "en espera", "corriendo": "convirtiendo", "listo": "convertido", "verificado": "verificado", "reemplazado": "reemplazado",
"error": "falló", "cancelado": "cancelado", "descartado": "no compensa"}
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 _motor_de(formato, chroma, ancho=0, alto=0):
"""Con qué se codifica un formato en este equipo, «gpu» o «cpu». Si no se puede: None y el porqué."""
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
_m = motores()
motor = _m["formatos"].get(formato)
lado = 4096 if fm["codec"] == "h264" else 8192
if motor == "gpu" and max(ancho or 0, alto or 0) > lado:
# la tarjeta no codifica imágenes de más de 4096 píxeles de lado en H.264 (un clip de 4.6K ya se pasa:
# «No capable devices found») ni de más de 8192 en H.265 (un BRAW de 12K): esos van por CPU
motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None
if motor is None:
return None, f"la tarjeta gráfica no codifica {fm['codec'].upper()} de más de {lado} píxeles de lado y no hay codificador por CPU"
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:
return None, "esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0"
if not motor:
return None, f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)"
return motor, ""
def _args_video(t, fm, motor, fps):
"""Los argumentos de ffmpeg que codifican la imagen —los mismos venga el clip de un archivo o del lector
de BRAW— y el formato de píxel en que la quiere el codificador."""
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 + fm.get("cq_mas", 0))))
maxrate = int(t["kbps"] * 1.8)
gop = str(int(round((fps or 30) * 2)))
diez = fm["bits"] == 10
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)
return ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"], "yuv422p10le"
if motor == "gpu":
pix = ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p"
# «uhq» es el ajuste de más rendimiento de las tarjetas recientes. Medido el 5-oct-2026 con tres clips
# del estudio (BRAW de la URSA, BRAW vertical de la Pocket y XAVC-I 4K60 de la Sony), cada uno contra
# su referencia sin pérdida y a la misma calidad pedida: pesa entre un 19 y un 55 % menos que «hq» con
# la misma nota (VMAF −0,4 a +0,9 sobre 100), y en el cuadro peor sale igual o mejor en dos de los
# tres. No deja fijar los cuadros B, y solo se usa donde se midió: H.265 de 10 bits en 4:2:0.
uhq = bool(fm["codec"] == "hevc" and diez and chroma != "422" and motores()["gpu"].get("uhq"))
args = ["-c:v", fm["gpu"], "-pix_fmt", pix,
"-preset", "p6", "-tune", "uhq" if uhq else "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"]
args += ([] if uhq else ["-bf", "3", "-b_ref_mode", "middle"]) + ["-g", gop]
if fm["codec"] == "hevc":
args += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"]
else:
args += ["-profile:v", "high"]
return args, pix
pix = ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p"
args = ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), "-pix_fmt", pix,
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop]
if fm["codec"] == "hevc":
args += ["-tag:v", "hvc1"]
if diez:
args += ["-profile:v", "main422-10" if chroma == "422" else "main10"]
return args, pix
def _args_salida(fm, f):
"""Lo que no es imagen: el contenedor y el audio."""
# use_metadata_tags: sin ella el MP4 solo guarda las etiquetas que conoce y se pierden las de la cámara
# (ISO, balance de blancos, obturación, óptica, rollo y toma): medido el 5-oct-2026, 48 de 188
# conversiones de clips de Blackmagic habían perdido entre 24 y 36 etiquetas cada una.
contenedor = ["-movflags", "+faststart+use_metadata_tags" if fm["ext"] == ".mp4" else "use_metadata_tags"]
# …pero con ella también viajarían, como si fueran etiquetas, las marcas de formato del original
# («major_brand = XAVC» dentro de un archivo que ya no es XAVC): esas tres se quitan
contenedor += ["-metadata", "major_brand=", "-metadata", "minor_version=", "-metadata", "compatible_brands="]
if fm["codec"] == "prores":
audio = ["-c:a", "pcm_s16le"]
elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1:
audio = ["-c:a", "copy"]
else:
# con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4
audio = ["-c:a", "aac", "-b:a", "320k"]
return contenedor, audio
def _salida_ajena(t):
"""True si en la ruta de salida hay un archivo que no dejó este mismo trabajo: ese no se pisa. El que dejó
él (un intento que no pasó la verificación, y que «Reintentar» rehace) sí."""
salida = t["salida"]
if not os.path.exists(salida):
return False
try:
return not (t["bytes_out"] and os.path.getsize(salida) == t["bytes_out"])
except OSError:
return True
def _quitar_parcial(tmp):
"""Borra el archivo a medio escribir. Si ffmpeg aún lo tiene cogido (lo acaban de cortar), espera."""
for _ in range(40):
try:
if os.path.exists(tmp):
os.remove(tmp)
return
except OSError:
time.sleep(0.25)
def correr_ffmpeg(t):
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
formato = formato_de(t)
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
motor, pega = _motor_de(formato, t["chroma"] or "420", f["ancho"] if f else 0, f["alto"] if f else 0)
if not motor:
set_trabajo(t["id"], estado="error", log=pega)
return
salida = t["salida"]
if _salida_ajena(t):
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
return
os.makedirs(os.path.dirname(salida), exist_ok=True)
tmp = salida + ".parcial" + fm["ext"]
video, _ = _args_video(t, fm, motor, fps)
contenedor, audio = _args_salida(fm, f)
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
cmd += video + ["-map_metadata", "0"] + contenedor + audio
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)
_quitar_parcial(tmp)
return
if p.returncode != 0 or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
_quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
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_braw(t):
"""Un BRAW. Lo lee el decodificador de Blackmagic en un proceso aparte (braw.py), que le va pasando los
cuadros ya revelados a ffmpeg; el codificador y la calidad son los de cualquier otro clip. No hace
falta tener Resolve abierto, y si lo está no se le toca nada."""
formato = formato_de(t)
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
dec = decodificador_braw()
if not dec.get("ok"):
set_trabajo(t["id"], estado="error", log=dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
return
salida = t["salida"]
if _salida_ajena(t):
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
return
m = medir_braw(t["ruta"])
if not m.get("ok"):
set_trabajo(t["id"], estado="error", log=m.get("error") or "el lector de BRAW no pudo abrir el clip")
return
motor, pega = _motor_de(formato, t["chroma"] or "420", m["ancho"], m["alto"])
if not motor:
set_trabajo(t["id"], estado="error", log=pega)
return
# la tasa se calcula con el tamaño de verdad del clip, que es el que dice el decodificador
kbps = int(kbps_objetivo(m["ancho"], m["alto"], m["fps"], t["perfil"]) * fm.get("bpp_mult", 1.0))
t = dict(t, kbps=kbps)
con = db()
with con:
con.execute("UPDATE archivos SET ancho = ?, alto = ?, fps = ?, dur = ?, perfil = ? WHERE ruta = ?",
(m["ancho"], m["alto"], m["fps"], m["dur"], _curva(m), t["ruta"]))
con.execute("UPDATE trabajos SET kbps = ? WHERE id = ?", (kbps, t["id"]))
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
con.close()
os.makedirs(os.path.dirname(salida), exist_ok=True)
tmp = salida + ".parcial" + fm["ext"]
video, pix = _args_video(t, fm, motor, m["fps"])
contenedor, audio = _args_salida(fm, f)
ficha = os.path.join(DATOS, f"braw_{t['id']}.json")
bandera = ficha + ".cancelar"
guardar_json(ficha, {"src": t["ruta"], "tmp": tmp, "ffmpeg": ruta_herramienta("ffmpeg"), "pix": pix, "video": video,
"contenedor": contenedor, "audio": audio, "color": t["color"] or "rec709", "cancelar": bandera,
"sdk": dec.get("carpeta") or ""})
try:
os.remove(bandera)
except OSError:
pass
errores = tempfile.TemporaryFile()
try:
p = subprocess.Popen([sys.executable, os.path.join(BASE, "braw.py"), "convertir", ficha], stdout=subprocess.PIPE, stderr=errores,
text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
errores.close()
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}")
return
def vigia():
# Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se corta
while p.poll() is None:
if COLA["cancelar"]:
try:
open(bandera, "w").close()
p.wait(20)
except (OSError, subprocess.TimeoutExpired):
p.kill()
return
time.sleep(0.5)
threading.Thread(target=vigia, daemon=True).start()
fin, dicho = {}, ""
for linea in p.stdout:
try:
ev = json.loads(linea)
except ValueError:
continue
if ev.get("estado"):
fin = ev
elif "pct" in ev:
set_trabajo(t["id"], pct=ev["pct"])
elif ev.get("msg"):
dicho = ev["msg"]
set_trabajo(t["id"], log=dicho)
p.wait()
p.stdout.close()
try:
errores.seek(0)
cola_err = errores.read().decode("utf-8", "replace").strip()[-2000:]
except (OSError, ValueError):
cola_err = ""
errores.close()
for resto in (ficha, bandera):
try:
os.remove(resto)
except OSError:
pass
if COLA["cancelar"] or fin.get("estado") == "cancelado":
set_trabajo(t["id"], estado="cancelado", pct=0)
_quitar_parcial(tmp)
return
if fin.get("estado") != "listo" or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error",
log=(fin.get("log") or cola_err or f"el lector de BRAW se cerró sin acabar el clip (código {p.returncode})")[-3000:])
_quitar_parcial(tmp)
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(),
log=(f"{dicho} · {fin.get('segundos', 0)} s · " + " ".join(video))[:4000])
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)
# el puente corre con el mismo motor que el programa (en el paquete, su propio .exe haciendo de intérprete)
cmd = [o.get("python_resolve") or sys.executable, 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()
try:
os.remove(lote)
except OSError:
pass
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 IN ('ffmpeg', 'braw') ORDER BY orden DESC, 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 and not _esperar_turno():
break
if t:
COLA["actual"] = t["id"]
COLA["empezo"] = time.time()
set_trabajo(t["id"], estado="corriendo", pct=0)
try:
(correr_braw if t["motor"] == "braw" else correr_ffmpeg)(dict(t))
except Exception as ex: # noqa
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
_verificar_listos([t["id"]])
_anotar_ritmo(t["id"], time.time() - COLA["empezo"])
if CONFIG["optimizar"].get("auto_reemplazar"):
try: # solo reemplaza lo verificado; lo demás lo deja como está
reemplazar([t["id"]])
except Exception as ex: # noqa
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
COLA["hechos"] += 1
COLA["hechos_ids"].append(t["id"])
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)
COLA["hechos_ids"].extend(r["id"] for r in 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
COLA["esperando"] = ""
try:
_resumen_tanda()
except Exception as ex: # noqa
COLA["mensaje"] = f"no se pudo apuntar el resumen de la tanda: {ex}"
COLA["hechos_ids"] = []
def _anotar_ritmo(id_, segundos):
"""Cuánto clip salió por segundo real en este trabajo, si acabó bien: con eso se calcula cuánto falta."""
try:
con = db()
t = con.execute("SELECT estado, ruta FROM trabajos WHERE id = ?", (id_,)).fetchone()
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
con.close()
if t and t["estado"] in ("listo", "verificado", "reemplazado", "descartado") and f and (f["dur"] or 0) > 0 and segundos > 0:
COLA["ritmo"].append((float(f["dur"]), float(segundos)))
except Exception: # noqa
pass
def eta_cola(trabajos):
"""Cuánto falta para que acabe la cola: segundos de clip pendientes partidos por el ritmo medido (clips ya
hechos en esta tanda; si no hay ninguno, el avance del que va; si tampoco, a tiempo real los BRAW y a 2,5×
la tarjeta). Devuelve también el clip que va, con lo que le queda."""
pend = [t for t in trabajos if t["estado"] in ("pendiente", "corriendo")]
if not pend:
return None
con = db()
durs = {}
for t in pend:
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
durs[t["id"]] = float((f["dur"] if f else 0) or 0)
con.close()
ritmo = COLA.get("ritmo") or []
clip_s, real_s = sum(d for d, _ in ritmo), sum(s for _, s in ritmo)
vel, medido = (clip_s / real_s if clip_s > 0 and real_s > 0 else 0.0), "tanda" if ritmo else ""
falta, actual, ahora = 0.0, None, time.time()
for t in pend:
d = durs[t["id"]]
if t["estado"] == "corriendo":
pct = float(t["pct"] or 0)
resto = d * max(0.0, 1 - pct / 100)
falta += resto
llevado = ahora - (COLA.get("empezo") or ahora)
if not vel and pct >= 3 and llevado > 5:
vel, medido = (d * pct / 100) / llevado, "clip"
actual = {"id": t["id"], "dur": d, "resto": resto, "llevado": llevado}
else:
falta += d
if not vel:
vel = 1.0 if any(t["motor"] == "braw" for t in pend) else 2.5
seg = falta / vel if falta else 0.0
if actual:
actual["resto_seg"] = actual["resto"] / vel
return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel, 2), "medido": medido, "termina": ahora + seg if seg else 0,
"actual": actual, "en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)}
def _ruta_original(t):
"""Dónde está hoy el original de un trabajo: en su sitio o, si ya se reemplazó, en _Para borrar/originales."""
if os.path.isfile(t["ruta"]):
return t["ruta"]
try:
otra = os.path.join(t["raiz"], R.PAPELERA, "originales", os.path.relpath(t["ruta"], t["raiz"]))
except ValueError:
return ""
return otra if os.path.isfile(otra) else ""
def cuadro_de(id_, cual, segundo, ancho=1600):
"""Un cuadro (JPEG) del original o del convertido de un trabajo, en el segundo pedido; se guarda en
datos/cuadros para no sacarlo dos veces. Devuelve la ruta del JPEG o lanza ValueError."""
con = db()
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
con.close()
if not t:
raise ValueError("no existe ese trabajo")
segundo = max(0.0, float(segundo or 0))
carpeta = os.path.join(DATOS, "cuadros")
os.makedirs(carpeta, exist_ok=True)
salida = os.path.join(carpeta, f"{id_}_{'o' if cual == 'original' else 'c'}_{int(round(segundo * 10))}.jpg")
if os.path.isfile(salida) and os.path.getsize(salida) > 0:
return salida
if cual == "original":
src = _ruta_original(dict(t))
if not src:
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
else:
src = t["salida"]
if not os.path.isfile(src):
raise ValueError("el convertido ya no está donde se hizo")
if cual == "original" and t["motor"] == "braw":
dec = decodificador_braw()
if not dec.get("ok"):
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
ficha = os.path.join(DATOS, f"cuadro_{id_}.json")
guardar_json(ficha, {"src": src, "segundo": segundo, "salida": salida, "ffmpeg": ruta_herramienta("ffmpeg"),
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho})
try:
ev = [e for e in _lector_braw("cuadro", ficha, espera=180) if e.get("estado")]
finally:
try:
os.remove(ficha)
except OSError:
pass
if not ev or ev[-1].get("estado") != "listo":
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo sacar el cuadro")
else:
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{segundo:.3f}", "-i", src,
"-frames:v", "1", "-vf", f"scale={ancho}:-2:flags=bicubic,format=yuvj420p", "-q:v", "2", "-f", "image2", salida]
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, creationflags=SIN_VENTANA)
if r.returncode != 0 or not os.path.isfile(salida):
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo sacar el cuadro")
return salida
def calidad_de(id_, segundos=10, ancho=1920):
"""Nota de calidad (VMAF, 0 a 100) del convertido de un trabajo contra su original, sobre un tramo del medio
del clip. Se guarda en el trabajo (nota, nota_min). Lanza ValueError si no se puede."""
con = db()
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
con.close()
if not t:
raise ValueError("no existe ese trabajo")
src = _ruta_original(dict(t))
if not src:
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
if not os.path.isfile(t["salida"]):
raise ValueError("el convertido ya no está donde se hizo")
fps = float((f["fps"] if f else 0) or 0) or 25.0
dur = float((f["dur"] if f else 0) or 0)
total = int(dur * fps) if dur else 0
n = max(24, min(int(segundos * fps), 300, total or 300))
desde = max(0, (total - n) // 2) if total else 0
hilos = max(2, min(16, (os.cpu_count() or 8) - 2))
carpeta = os.path.join(DATOS, "cuadros")
os.makedirs(carpeta, exist_ok=True)
log = f"calidad_{id_}.json"
t0 = time.time()
if t["motor"] == "braw":
dec = decodificador_braw()
if not dec.get("ok"):
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
ficha = os.path.join(DATOS, f"calidad_{id_}.ficha.json")
guardar_json(ficha, {"src": src, "conv": t["salida"], "desde": desde, "cuadros": n, "ffmpeg": ruta_herramienta("ffmpeg"),
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho, "log": log, "cwd": carpeta, "vmaf_hilos": hilos})
try:
ev = [e for e in _lector_braw("calidad", ficha, espera=900) if e.get("estado")]
finally:
try:
os.remove(ficha)
except OSError:
pass
if not ev or ev[-1].get("estado") != "listo":
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo medir")
nota, minimo, cuadros = ev[-1]["vmaf"], ev[-1]["minimo"], ev[-1]["medidos"]
else:
fc = (f"[0:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r];"
f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d];"
f"[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={log}")
ss = f"{desde / fps:.6f}"
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", ss, "-i", src, "-ss", ss, "-i", t["salida"],
"-filter_complex", fc, "-frames:v", str(n), "-an", "-f", "null", "-"]
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, cwd=carpeta, creationflags=SIN_VENTANA)
ruta_log = os.path.join(carpeta, log)
if r.returncode != 0 or not os.path.isfile(ruta_log):
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo medir (¿este ffmpeg trae libvmaf?)")
with open(ruta_log, "r", encoding="utf-8") as fh:
j = json.load(fh)
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
nota, minimo, cuadros = round(float(v.get("mean") or 0), 1), round(float(v.get("min") or 0), 1), len(j.get("frames") or [])
try:
os.remove(os.path.join(carpeta, log))
except OSError:
pass
set_trabajo(id_, nota=nota, nota_min=minimo)
return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)}
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)
def braws_al_lector():
"""Las conversiones de BRAW que quedaron en espera o fallidas por la vía de Resolve pasan al lector
directo. De cada clip se queda una sola, la última; las que apuntan a un clip que ya no está en esa ruta
—la carpeta se reordenó después— se quitan, porque reintentarlas no podía salir bien."""
if not decodificador_braw().get("ok"):
return 0
con = db()
n = 0
with con:
vistos = set()
for t in con.execute("SELECT id, ruta, raiz, formato FROM trabajos WHERE motor = 'resolve' "
"AND estado IN ('pendiente','error','cancelado') ORDER BY id DESC").fetchall():
if not os.path.isdir(t["raiz"] or ""):
continue # unidad desconectada: no se sabe si el clip sigue ahí
clave = (os.path.normcase(t["ruta"]), t["formato"] or "h265_10bit")
if clave in vistos or not os.path.isfile(t["ruta"]):
con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],))
else:
vistos.add(clave)
con.execute("UPDATE trabajos SET motor = 'braw' WHERE id = ?", (t["id"],))
con.execute("UPDATE trabajos SET log = ? WHERE id = ? AND estado = 'error'",
("no salió por la vía de Resolve; ahora se lee sin Resolve: pulsa «Reintentar los que fallaron»", t["id"]))
n += 1
con.close()
return n
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()
try:
braws_al_lector()
except Exception: # noqa
pass
COLA["activa"] = True
COLA["cancelar"] = False
COLA["hechos"] = 0
COLA["inicio"] = time.time()
COLA["ritmo"] = []
COLA["hechos_ids"] = []
COLA["esperando"] = ""
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
def _no_compensa(t, sale=None):
"""Por qué no merece la pena dar por buena esta conversión, o «» si sí. Solo cuenta para los formatos que
existen para pesar menos (H.265). El 5-oct-2026 había 8 conversiones verificadas esperando en la cola de
V:\\Galeano con el botón «Reemplazar 8 verificados»: seis eran reconversiones de un clip ya convertido
y pesaban entre el 85 y el 99 % de él. Reemplazar habría cambiado cada uno por una copia de su copia."""
if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc":
return ""
entra, sale = t["bytes_in"] or 0, sale or t["bytes_out"] or 0
if not entra or not sale:
return ""
repetida = R.es_conversion(os.path.basename(t["ruta"]))
if not repetida and sale <= 0.8 * entra:
return ""
return (f"no compensa: pesa el {round(sale / entra * 100)} % del clip del que salió"
+ (", que ya era una conversión de este programa" if repetida else "") + ". Se queda el que estaba.")
DESCARTADOS = "descartados"
def _apartar_convertido(t, lote, movidos):
"""Lo que salió de una conversión que no se usa va a _Para borrar\\descartados, con su ruta: no se borra, y
«Deshacer» lo devuelve. Solo si sigue siendo el archivo que dejó este trabajo. Devuelve dónde quedó."""
salida, raiz = t["salida"] or "", t["raiz"] or ""
if not os.path.isfile(salida) or not raiz or not dentro_de(raiz, salida):
return ""
if t["bytes_out"] and os.path.getsize(salida) != t["bytes_out"]:
return ""
antes = len(movidos)
mover(salida, os.path.join(raiz, R.PAPELERA, DESCARTADOS, os.path.relpath(salida, raiz)), lote, movidos)
return movidos[-1][1] if len(movidos) > antes else ""
def _cerrar_lote(lote, raiz, movidos, errores):
if movidos or errores:
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos), "movimientos": len(movidos), "errores": errores})
def _descartar(t, pega, lote, movidos, errores, **kw):
"""Marca el trabajo como «no compensa» y aparta lo que salió de él."""
nota = ""
try:
if _apartar_convertido(t, lote, movidos):
nota = f" Lo convertido quedó en «{R.PAPELERA}\\{DESCARTADOS}»."
except OSError as ex:
errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}")
nota = " Lo convertido sigue junto al original (no se pudo apartar: ¿está abierto?)."
set_trabajo(t["id"], estado="descartado", log=pega + nota, **kw)
return pega + nota
def apartar_descartados(ids):
"""«Limpiar terminados»: lo que salió de las conversiones que no compensaron y sigue junto a su original se
aparta antes de quitarlas de la cola. Devuelve también las que no se pudieron apartar: esas se quedan."""
con = db()
filas = [dict(r) for r in con.execute(f"SELECT * FROM trabajos WHERE estado = 'descartado' AND id IN ({','.join('?' * len(ids))})",
list(ids)).fetchall()] if ids else []
con.close()
lote, movidos, errores, raiz, quedan = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "", []
for t in filas:
try:
if _apartar_convertido(t, lote, movidos):
raiz = raiz or t["raiz"]
except OSError as ex:
errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}")
quedan.append(t["id"])
_cerrar_lote(lote, raiz or (filas[0]["raiz"] if filas else ""), movidos, errores)
return {"movidos": len(movidos), "errores": errores, "quedan": quedan}
def repasar_verificados():
"""Las conversiones verificadas con una versión anterior que no compensan dejan de ofrecerse para reemplazar.
Al arrancar solo cambia su estado: lo que salió de ellas se aparta cuando alguien limpia la cola o vuelve a
convertir el clip, no sin que nadie lo pida."""
con = db()
n = 0
for t in con.execute("SELECT * FROM trabajos WHERE estado = 'verificado'").fetchall():
pega = _no_compensa(t)
if pega:
with con:
con.execute("UPDATE trabajos SET estado = 'descartado', log = ? WHERE id = ?", (pega, t["id"]))
n += 1
con.close()
return n
def verificar(ids):
con = db()
res = []
lote, movidos, errores, raiz_lote = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
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, ancho, alto 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 t["motor"] == "braw" and f and f["ancho"] and (info["ancho"], info["alto"]) != (f["ancho"], f["alto"]):
problemas.append(f"imagen de {info['ancho']}×{info['alto']} (el clip es de {f['ancho']}×{f['alto']})")
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:
sale = os.path.getsize(t["salida"])
pega = _no_compensa(t, sale)
if pega: # está bien hecha, pero no merece ocupar el sitio del original
raiz_lote = raiz_lote or t["raiz"]
res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t, bytes_out=sale), pega, lote, movidos, errores, bytes_out=sale)})
else:
set_trabajo(id_, estado="verificado", bytes_out=sale)
res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"})
con.close()
_cerrar_lote(lote, raiz_lote, movidos, errores)
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, ""
# lo que no compensa va en su propio lote: deshacer el reemplazo no debe devolver también eso
lote_desc, movidos_desc, errores_desc, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
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
pega = _no_compensa(t)
if pega:
raiz_desc = raiz_desc or t["raiz"]
res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t), pega, lote_desc, movidos_desc, errores_desc)})
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 .sidecar de un BRAW (los ajustes que le dejó Resolve) se van con el original
base, ext = os.path.splitext(t["ruta"])
for extra in (base + "M01.xml", base + "M01.XML", base + ".sidecar"):
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"] and not r["motivo"].startswith("no compensa")]})
_cerrar_lote(lote_desc, raiz_desc, movidos_desc, errores_desc)
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"])
# la licencia, el «leeme» y el .zip que vienen dentro de un paquete de Envato se van con él: si
# no, la lista de acciones seguía contando stock viejo que ningún botón llegaba a apartar
if t not in tipos and not (t in ("Otros", "Documentos") and 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 _medir(rutas):
"""Duración y tamaño de imagen de unos clips. Lo que ya leyó ffprobe está en el inventario; lo demás se
lee ahora (solo la cabecera) y se apunta para la próxima vez."""
import concurrent.futures as cf
out, faltan = {}, []
con = db()
for r in rutas:
f = con.execute("SELECT analizado, dur, ancho, alto, kbps FROM archivos WHERE ruta = ?", (r,)).fetchone()
if f and f["analizado"] == 1 and f["dur"]:
out[r] = {"dur": f["dur"], "ancho": f["ancho"] or 0, "alto": f["alto"] or 0, "kbps": f["kbps"] or 0}
else:
faltan.append(r)
if faltan and herramientas()["ffprobe"]["ok"]:
with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool:
leidos = list(zip(faltan, pool.map(ffprobe, faltan)))
with con:
for r, info in leidos:
if not info or not info.get("dur"):
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ? AND analizado = 0", (r,))
continue
out[r] = {"dur": info["dur"], "ancho": info["ancho"], "alto": info["alto"], "kbps": info["kbps"]}
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), r))
con.close()
return out
_ROLES_TOMA = ("camara_raw", "camara_video", "celular_video")
def _por_comparar(filas):
"""Clips de cámara que se llaman igual y no pesan lo mismo: candidatos a ser el mismo clip con otra
calidad. Solo material de cámara: dos exportados con el mismo nombre son dos versiones de una pieza
(la vertical y la horizontal, la de antes y la de después de un cambio), y cuál es «la buena» no lo
decide el peso."""
por_nombre = defaultdict(list)
for f in filas:
if (f["ext"] or "").lower() in _EXT_VIDEO_DUP and f["rol"] in _ROLES_TOMA:
por_nombre[f["nombre"].lower()].append(f)
return [fs for fs in por_nombre.values() if len(fs) > 1 and len({f["bytes"] for f in fs}) > 1]
def _mismas_tomas(candidatos, tope=400):
"""El mismo clip guardado dos veces con distinta calidad. Medido el 4-oct-2026 sobre el material real:
39 clips de un rodaje estaban en dos carpetas con el mismo nombre, unos de 220 MB y otros de 76 MB, y
el plan no podía juntarlos en su mes porque dos archivos que se llaman igual no caben en una carpeta.
Que se llamen igual no basta: dos tarjetas de la misma cámara traen las dos un C0001.MP4. Tienen que
durar lo mismo. De los que duran lo mismo se queda el de más imagen y, a igualdad, el que más pesa;
los demás van a «Duplicados» a su lado, para mirarlos a mano. No se borra nada."""
candidatos = candidatos[:tope]
medidas = _medir([f["ruta"] for fs in candidatos for f in fs])
grupos = []
for fs in candidatos:
def forma(m): # 1.78 en horizontal, 0.56 en vertical
return round(m["ancho"] / m["alto"], 1) if m["alto"] else 0
medidos = sorted(((f, medidas[f["ruta"]]) for f in fs if f["ruta"] in medidas), key=lambda x: (forma(x[1]), x[1]["dur"]))
racimos, actual = [], []
for f, m in medidos:
if actual and (forma(m) != forma(actual[0][1])
or abs(m["dur"] - actual[0][1]["dur"]) > max(0.5, 0.01 * actual[0][1]["dur"])):
racimos.append(actual)
actual = []
actual.append((f, m))
if actual:
racimos.append(actual)
for rc in racimos:
if len(rc) < 2 or len({f["bytes"] for f, _ in rc}) < 2:
continue
rc.sort(key=lambda x: (-(x[1]["ancho"] * x[1]["alto"]), -(x[0]["bytes"] or 0), len(x[0]["ruta"])))
(fq, mq), resto = rc[0], rc[1:]
def dice(f, m):
return f"{m['ancho']}x{m['alto']} · {_gb(f['bytes'])}"
grupos.append({"tipo": "calidad", "nombre": fq["nombre"], "queda": fq["ruta"], "mueven": [f["ruta"] for f, _ in resto],
"bytes": sum(f["bytes"] or 0 for f, _ in resto),
"destino": os.path.join(os.path.dirname(fq["ruta"]), R.DUPLICADOS),
"detalle": "se queda la de " + dice(fq, mq) + "; se aparta la de " + ", ".join(dice(f, m) for f, m in resto)})
return grupos
def duplicados(raiz, minimo=5_000_000, calidades=False):
"""Grupos de archivos repetidos. Tres 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.
calidad = mismo nombre y misma duración, distinto peso: el mismo clip con otra calidad. Hay que leer
los clips para saberlo, así que solo se calcula si se pide (`calidades`); si no, se dice
cuántos hay por comparar."""
con = db()
filas = [dict(r) for r in con.execute(
"SELECT ruta, dir, nombre, ext, bytes, rol 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)})
for g in grupos:
if g["tipo"] == "version":
usados.add(g["queda"])
usados.update(g["mueven"])
candidatos = _por_comparar([f for f in filas if f["ruta"] not in usados])
if calidades and candidatos:
grupos += _mismas_tomas(candidatos)
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"),
"calidades": sum(1 for g in grupos if g["tipo"] == "calidad"),
# clips que se llaman igual y pesan distinto, sin comparar todavía
"por_comparar": 0 if calidades else sum(len(fs) - 1 for fs in candidatos)}
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, calidades=True)["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
PLAN_GEN = defaultdict(int) # raiz -> cuántas veces se ha olvidado su plan: un recálculo viejo no pisa uno nuevo
REPLAN = {} # raiz -> el hilo que está volviendo a pensar el plan después de mover algo
PLANES_RESPALDO = {}
def olvidar_plan(raiz, recalcular=True):
"""Después de mover algo, el plan calculado ya no describe la carpeta: se olvida y, salvo que se diga lo
contrario, se vuelve a pensar en segundo plano. Así Inicio dice lo que de verdad queda (o que no queda
nada) en vez de ofrecer otra vez lo que se acaba de hacer, o de pedir que se calcule. Medido el
14-oct-2026 en el laboratorio: tras «Hacer las 5 de una vez», Inicio seguía diciendo «Ordenar por año y
mes · 5 cambios · Ver y aplicar» y «Plan listo: 7 de 9 movimientos marcados» con todo ya movido, porque
el plan solo se olvidaba al aplicar desde la pestaña «Qué va a pasar»; y tras «Deshacer», al revés:
«Lo seguro ya está hecho» con la carpeta otra vez en desorden."""
k = _clave(raiz)
PLANES.pop(k, None)
PLAN_GEN[k] += 1
if not recalcular or not raiz_cfg(k):
return
gen = PLAN_GEN[k]
def _pensar():
try:
plan = Plan(raiz_cfg(k)).correr()
if PLAN_GEN[k] == gen: # nadie movió nada más mientras tanto
PLANES[k] = plan
except Exception as ex: # noqa
print(f"no se pudo volver a pensar el plan de {k}: {ex}", file=sys.stderr)
finally:
if REPLAN.get(k) is threading.current_thread():
REPLAN.pop(k, None)
t = threading.Thread(target=_pensar, daemon=True)
REPLAN[k] = t
t.start()
def plan_en_marcha(raiz):
"""Si el plan de esta carpeta se está volviendo a pensar, espera a que acabe y lo devuelve; si no, None.
Lo usan «Calcular» y la pasada automática para no recorrer la carpeta dos veces seguidas."""
k = _clave(raiz)
t = REPLAN.get(k)
if t is None or t is threading.current_thread():
return None
t.join()
return PLANES.get(k)
def _clave(ruta):
return os.path.normpath(ruta)
_ELIGIENDO = threading.Lock()
VENTANA = None # la ventana del programa (ventana.Ventana) mientras está abierta
ULTIMA_PETICION = [0.0] # cuándo preguntó algo la interfaz por última vez
def elegir_carpeta(titulo="Elige la carpeta del cliente"):
"""Abre el selector de carpetas de Windows y devuelve la ruta o None. Con la ventana del programa abierta
es el suyo, modal sobre ella; si la interfaz va en el navegador, uno aparte en PowerShell."""
if not _ELIGIENDO.acquire(blocking=False):
raise ValueError("ya hay un selector de carpetas abierto")
try:
v = VENTANA
if v is not None:
ruta = v.elegir_carpeta(titulo)
return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None
ps = ("Add-Type -AssemblyName System.Windows.Forms; "
"$d = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$d.Description = '" + titulo.replace("'", "''") + "'; $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 [])},
"traer": {k: v for k, v in TRAER.items() if k != "errores"} | {"errores_n": len(TRAER.get("errores") or [])},
"automatico": dict(AUTO),
"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ó"),
"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, "AUDIO" if rol == "audio_grabado" else "OTROS")
if cub == "CELULAR" and tipo == "contenido":
cub, kw["sub"] = "GRABACION", "CELULAR"
if cub in ("GRABACION", "CELULAR"):
kw["dia"] = R.nombre_dia(fecha)
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)
dia = kw.get("dia", "")
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}
# ==========================================================================
# Material nuevo: pieza nueva, bandeja de entrada y orden automático
# ==========================================================================
_RX_NOMBRE_MALO = re.compile(r'[\\/:*?"<>|]')
_SERIE = [0]
def _subcarpetas_pieza():
"""Las subcarpetas con las que nace una pieza. Se cambian en config.json («pieza» › «subcarpetas»)."""
subs = (CONFIG.get("pieza") or {}).get("subcarpetas")
if not isinstance(subs, list):
subs = CONFIG_DEFECTO["pieza"]["subcarpetas"]
return [s.strip() for s in subs if isinstance(s, str) and s.strip() and not _RX_NOMBRE_MALO.search(s)][:20]
def _sitio_pieza(raiz, tipo, titulo, anio, mes):
"""(carpeta donde va, nombre que le toca, la que ya existe con ese título o None)."""
pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get("PIEZA") or "{anio}/{mes}/{nombre}"
destino = os.path.join(raiz, destino_plantilla(tipo, "PIEZA", anio=anio, mes=mes, nombre=titulo, sub=titulo))
padre = os.path.dirname(destino)
if "{mes}" not in pl: # campañas y proyectos: sin número
return padre, os.path.basename(destino), destino if os.path.isdir(destino) else None
numeros, ya = [], None
try:
with os.scandir(padre) as it:
for e in it:
if not e.is_dir(follow_symlinks=False):
continue
p = R.detectar_pieza(e.name)
if p:
numeros.append(p[0])
if R.norm(p[1] if p else e.name) == R.norm(titulo):
ya = e.path
except OSError:
pass # el mes todavía no existe: será la primera
return padre, R.nombre_pieza(max(numeros, default=0) + 1, titulo), ya
def nueva_pieza(raiz, titulo, anio=None, mes=None, crear=True):
"""Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas.
Las piezas se venían creando a mano, y de ahí salían dos «01» en el mismo mes, «03 - Título» junto a
«03- Título» y subcarpetas con el nombre a medio escribir («portasda», «Imagnes»). Con `crear=False`
solo dice cómo quedaría."""
raiz = _clave(raiz)
r = raiz_cfg(raiz)
if not r:
return {"error": "carpeta no registrada"}
titulo = limpiar_nombre(re.sub(r"\s+", " ", titulo or "")).strip(". ")
p = R.detectar_pieza(titulo)
if p:
titulo = limpiar_nombre(p[1]) # «03- Botox»: el número lo pone la app
if not titulo:
return {"error": "ponle un título a la pieza"}
if _RX_NOMBRE_MALO.search(titulo):
return {"error": 'el título no puede llevar \\ / : * ? " < > |'}
if len(titulo) > 80:
return {"error": "el título es demasiado largo: 80 letras como mucho"}
hoy = datetime.date.today()
try:
anio, mes = int(anio or hoy.year), int(mes or hoy.month)
except (TypeError, ValueError):
return {"error": "el año o el mes no valen"}
if not (2000 <= anio <= 2100 and 1 <= mes <= 12):
return {"error": "el año o el mes no valen"}
padre, nombre, ya = _sitio_pieza(raiz, r.get("tipo", "contenido"), titulo, anio, mes)
destino = os.path.join(padre, nombre)
subs = _subcarpetas_pieza()
out = {"raiz": raiz, "ruta": destino, "rel": os.path.relpath(destino, raiz), "nombre": nombre,
"subcarpetas": subs, "existe": os.path.relpath(ya, raiz) if ya else ""}
if not crear:
return out
if ya:
return {**out, "error": f"esa pieza ya existe: «{os.path.relpath(ya, raiz)}»"}
if not os.path.isdir(raiz):
return {**out, "error": "la carpeta del cliente no está conectada"}
_SERIE[0] += 1
lote = "pieza-" + time.strftime("%Y%m%d-%H%M%S") + f"-{_SERIE[0]}" # dos seguidas no comparten lote
faltan, d = [], destino # las de encima que aún no existen (el año, el mes)
while not os.path.isdir(d) and dentro_de(raiz, d) and d.lower() != raiz.lower():
faltan.append(d)
d = os.path.dirname(d)
creadas = []
try:
for d in list(reversed(faltan)) + [os.path.join(destino, s) for s in subs]:
os.mkdir(d)
bitacora({"lote": lote, "op": "mkdir", "de": d})
creadas.append(d)
except OSError as ex:
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 0, "movimientos": len(creadas), "errores": [str(ex)]})
return {**out, "error": f"no se pudo crear: {ex.strerror or ex}"}
con = db()
with con:
for d in creadas:
con.execute("INSERT OR IGNORE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)", (d, raiz, os.path.basename(d)))
con.close()
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 1, "movimientos": len(creadas), "errores": []})
olvidar_plan(raiz)
return {**out, "ok": True, "lote": lote, "creadas": len(creadas)}
# --------------------------------------------------------------------------
# La bandeja de entrada
# --------------------------------------------------------------------------
#
# El desorden nace el día que se vuelca la tarjeta: se copia donde se puede, con el nombre que sale. La
# bandeja es una carpeta «_Entrada» dentro de la del cliente; lo que se deje ahí —a mano, o trayéndolo
# desde la tarjeta con la copia comprobada— el plan lo abre siempre y lo reparte por rodaje y cámara.
TRAER = {"estado": "", "raiz": "", "origen": "", "destino": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0,
"actual": "", "copiados": 0, "saltados": 0, "errores": [], "cancelar": False, "inicio": 0, "fin": 0,
"ordenado": None}
_NO_TARJETA = {"system volume information", "$recycle.bin", ".trashes", ".spotlight-v100", ".fseventsd",
".temporaryitems", "lost.dir", ".trash-1000"}
def _ruta_entrada(raiz):
return os.path.join(_clave(raiz), R.ENTRADA)
def entrada(raiz):
"""Qué hay esperando en la bandeja. Mira el disco y no el inventario, que puede ser de ayer."""
raiz = _clave(raiz)
base = _ruta_entrada(raiz)
n = b = 0
ejemplos = []
if os.path.isdir(base):
for d, _dirs, fs in os.walk(base):
for f in fs:
if R.es_basura_archivo(f) or f.endswith(".copiando"):
continue
try:
b += os.path.getsize(os.path.join(d, f))
except OSError:
continue
n += 1
if len(ejemplos) < 5:
ejemplos.append(os.path.relpath(os.path.join(d, f), base))
return {"raiz": raiz, "carpeta": base, "existe": os.path.isdir(base), "n": n, "bytes": b, "ejemplos": ejemplos,
"copia": dict(TRAER) if TRAER.get("raiz") == raiz and TRAER.get("estado") else None}
def abrir_entrada(raiz):
"""Crea la bandeja si no existe y la abre en el Explorador, para soltar material dentro."""
raiz = _clave(raiz)
if not raiz_cfg(raiz) or not os.path.isdir(raiz):
return {"error": "la carpeta del cliente no está conectada"}
base = _ruta_entrada(raiz)
os.makedirs(base, exist_ok=True)
try:
os.startfile(base)
except OSError:
pass
return {"ok": True, "carpeta": base}
def _podar(base, lote):
"""Quita de la bandeja las carpetas que se quedaron sin un solo archivo: el esqueleto de una tarjeta
ya repartida (PRIVATE, M4ROOT, CLIP…). Queda apuntado en el mismo lote, así que «Deshacer» las
vuelve a crear antes de devolver los archivos."""
quitadas = []
for d, _dirs, _fs in os.walk(base, topdown=False):
if os.path.normcase(d) == os.path.normcase(base):
continue
try:
if not os.listdir(d):
os.rmdir(d)
bitacora({"lote": lote, "op": "rmdir", "de": d})
quitadas.append(d)
except OSError:
pass
if quitadas:
con = db()
with con:
for d in quitadas:
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
con.close()
def ordenar_entrada(raiz):
"""Lee la carpeta otra vez y aplica, del plan, lo que sale de la bandeja y va marcado."""
raiz = _clave(raiz)
r = raiz_cfg(raiz)
if not r:
return {"error": "carpeta no registrada"}
if asistente.ESTADO["activo"] or (APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]):
return {"error": "ya hay algo en marcha en esta carpeta"}
escanear(raiz)
prog = PROGRESO.get(raiz) or {}
if prog.get("estado") != "listo":
return {"error": "no se pudo leer la carpeta: " + (prog.get("error") or "¿está conectada?")}
plan = Plan(r).correr()
base = _ruta_entrada(raiz)
filas, dudosos = [], 0
for f in plan["filas"]:
if f.get("archivos"):
mios = [a for a in f["archivos"] if dentro_de(base, a)]
if not mios:
continue
if len(mios) < len(f["archivos"]): # la fila de «basura repartida por la carpeta»: solo lo de aquí
f = dict(f, archivos=mios)
if f.get("destinos_archivo"):
f["destinos_archivo"] = {a: d for a, d in f["destinos_archivo"].items() if a in set(mios)}
elif not dentro_de(base, f["origen"]):
continue
if f["marcado"]:
filas.append(f)
else:
dudosos += 1
res = aplicar(raiz, filas) if filas else {"lote": None, "movimientos": 0, "errores": []}
PLANES.pop(raiz, None)
try:
os.makedirs(base, exist_ok=True) # la bandeja se queda, vacía, para la próxima vez
except OSError:
pass
if res.get("lote"):
_podar(base, res["lote"])
return {"ok": True, "lote": res.get("lote"), "movimientos": res.get("movimientos", 0), "errores": res.get("errores", []),
"quedan": entrada(raiz)["n"], "dudosos": dudosos,
"avisos": [a for a in plan["avisos"] if R.ENTRADA in a]}
def _archivos_de(origen):
"""Los archivos de una tarjeta o de una carpeta cualquiera, sin lo que es del sistema."""
for d, dirs, fs in os.walk(origen):
dirs[:] = sorted(x for x in dirs if x.lower() not in _NO_TARJETA)
for f in sorted(fs):
if f.lower() in ("thumbs.db", "desktop.ini", ".ds_store") or f.startswith("._"):
continue
yield os.path.join(d, f)
def plan_traer(raiz, origen, nombre=""):
"""Qué se copiaría de una tarjeta a la bandeja, y si cabe. No copia nada."""
raiz = _clave(raiz)
if not raiz_cfg(raiz):
return {"error": "carpeta no registrada"}
if not os.path.isdir(raiz):
return {"error": "la carpeta del cliente no está conectada"}
origen = os.path.normpath(origen or "")
if not origen or not os.path.isdir(origen):
return {"error": "elige la tarjeta o la carpeta de la que traer el material"}
if dentro_de(raiz, origen) or dentro_de(origen, raiz):
return {"error": "eso ya está dentro de la carpeta del cliente: no hace falta traerlo"}
if _raiz_de(origen):
return {"error": "esa carpeta es de otro cliente registrado; desde aquí solo se trae material de fuera"}
nombre = limpiar_nombre(re.sub(r"\s+", " ", nombre or "")).strip(". ")
if _RX_NOMBRE_MALO.search(nombre):
return {"error": 'el nombre no puede llevar \\ / : * ? " < > |'}
# sin nombre, la carpeta se llama «Tarjeta <fecha>»: un nombre que el plan no toma por el de una sesión
carpeta = nombre or ("Tarjeta " + time.strftime("%Y-%m-%d %H%M"))
n = b = 0
for a in _archivos_de(origen):
try:
b += os.path.getsize(a)
n += 1
except OSError:
pass
if not n:
return {"error": "ahí no hay ningún archivo"}
try:
libre = shutil.disk_usage(raiz).free
except OSError:
libre = None
out = {"raiz": raiz, "origen": origen, "destino": os.path.join(_ruta_entrada(raiz), carpeta), "carpeta": carpeta,
"n": n, "bytes": b, "libre": libre}
if libre is not None and b > libre:
out["error"] = f"no cabe: son {_gb(b)} y en el disco del cliente quedan {_gb(libre)}"
return out
def _copiar_comprobando(origen, destino, estado):
"""Copia un archivo y vuelve a leer lo copiado para comprobar que quedó igual. True si copió, False
si ya estaba. Del origen solo se lee: ni se mueve ni se borra."""
st = os.stat(origen)
try:
sd = os.stat(destino)
if sd.st_size == st.st_size and abs(sd.st_mtime - st.st_mtime) < 2:
estado["bytes"] += st.st_size
return False # ya se trajo antes: una copia a medias se retoma
raise FileExistsError("en la bandeja ya hay otro archivo distinto con ese nombre")
except FileNotFoundError:
pass
os.makedirs(os.path.dirname(destino), exist_ok=True)
tmp = destino + ".copiando"
antes = estado["bytes"]
try:
h1 = hashlib.blake2b(digest_size=16)
with open(origen, "rb") as a, open(tmp, "wb") as b:
while True:
if estado.get("cancelar"):
raise InterruptedError("cancelado")
trozo = a.read(4 << 20)
if not trozo:
break
h1.update(trozo)
b.write(trozo)
estado["bytes"] += len(trozo)
b.flush()
os.fsync(b.fileno())
h2 = hashlib.blake2b(digest_size=16)
with open(tmp, "rb") as b:
while True:
if estado.get("cancelar"):
raise InterruptedError("cancelado")
trozo = b.read(4 << 20)
if not trozo:
break
h2.update(trozo)
if h1.digest() != h2.digest() or os.path.getsize(tmp) != st.st_size:
raise OSError("lo copiado no quedó igual que el original")
os.replace(tmp, destino)
shutil.copystat(origen, destino) # la fecha del archivo es la del rodaje: se conserva
return h1.hexdigest()
except BaseException:
estado["bytes"] = antes
try:
os.remove(tmp)
except OSError:
pass
raise
def traer(raiz, origen, nombre="", ordenar=True, en_hilo=False):
"""Trae a la bandeja lo que hay en una tarjeta (o en cualquier carpeta de fuera), comprobando cada
archivo, y al terminar lo reparte. El origen no se toca."""
p = plan_traer(raiz, origen, nombre)
if p.get("error"):
return p
if TRAER.get("estado") in ("copiando", "ordenando"):
return {"error": "ya se está trayendo material"}
TRAER.update(estado="copiando", raiz=p["raiz"], origen=p["origen"], destino=p["destino"], hechos=0, total=p["n"],
bytes=0, bytes_total=p["bytes"], actual="", copiados=0, saltados=0, errores=[], cancelar=False,
inicio=time.time(), fin=0, ordenado=None)
if en_hilo:
threading.Thread(target=_traer, args=(p, ordenar), daemon=True).start()
return {"lanzado": True, **p}
return _traer(p, ordenar)
def _traer(p, ordenar):
raiz = p["raiz"]
lote = "entrada-" + time.strftime("%Y%m%d-%H%M%S")
comprobados = []
try:
for a in _archivos_de(p["origen"]):
if TRAER["cancelar"]:
break
rel = os.path.relpath(a, p["origen"])
TRAER["actual"] = rel
try:
h = _copiar_comprobando(a, os.path.join(p["destino"], rel), TRAER)
if h:
TRAER["copiados"] += 1
comprobados.append((rel, os.path.getsize(a), h))
else:
TRAER["saltados"] += 1
except InterruptedError:
break
except Exception as ex: # noqa
TRAER["errores"].append(f"{rel}: {getattr(ex, 'strerror', None) or ex}")
TRAER["hechos"] += 1
# la lista de lo que se copió y con qué huella queda en la carpeta de la app dentro de la del cliente
if comprobados:
try:
d = os.path.join(raiz, R.CARPETA_APP, "entradas")
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, lote + ".csv"), "w", encoding="utf-8-sig", newline="") as fh:
fh.write("archivo;bytes;huella\r\n")
for rel, tam, h in comprobados:
fh.write(f"{rel};{tam};{h}\r\n")
except OSError:
pass
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": TRAER["hechos"], "movimientos": TRAER["copiados"],
"errores": list(TRAER["errores"]), "origen": p["origen"], "destino": p["destino"]})
if TRAER["cancelar"]:
TRAER["estado"] = "cancelado"
elif TRAER["errores"]:
TRAER["estado"] = "error"
else:
if ordenar:
TRAER.update(estado="ordenando", actual="repartiendo por rodaje y cámara")
TRAER["ordenado"] = ordenar_entrada(raiz)
TRAER["estado"] = "listo"
except Exception as ex: # noqa
TRAER["errores"].append(str(ex))
TRAER["estado"] = "error"
TRAER.update(fin=time.time(), actual="")
return dict(TRAER)
# --------------------------------------------------------------------------
# Orden automático: todas las carpetas, lo seguro, sin que nadie pulse nada
# --------------------------------------------------------------------------
AUTOMATICO_PATH = os.path.join(DATOS, "automatico.json")
AUTO = {"activo": False, "raiz": "", "hechas": 0, "total": 0, "inicio": 0}
TAREA_WINDOWS = "OrganiLAB - orden automatico"
def automatico_una(raiz, limite):
"""Pone al día una carpeta sin preguntar: la lee y hace solo lo que el plan da por seguro. Si no se
puede leer entera, o si el plan quiere mover demasiado para hacerlo sin que alguien lo mire, no toca
nada y lo dice."""
raiz = _clave(raiz)
r = raiz_cfg(raiz) or {}
out = {"raiz": raiz, "cliente": r.get("cliente", ""), "ts": time.time(), "estado": "", "detalle": "",
"movimientos": 0, "lotes": [], "dudosos": 0, "avisos": [], "errores": []}
if not os.path.isdir(raiz):
out.update(estado="sin conexion", detalle="la carpeta no está conectada")
return out
escanear(raiz)
prog = PROGRESO.get(raiz) or {}
if prog.get("estado") != "listo" or prog.get("fallos"):
out.update(estado="sin leer", detalle=prog.get("error") or "no se pudo leer entera")
return out
plan = Plan(r).correr()
marcadas = [f for f in plan["filas"] if f["marcado"]]
n = sum(f["n"] or 0 for f in marcadas)
if n > limite:
out.update(estado="demasiado", dudosos=sum(1 for f in plan["filas"] if not f["marcado"]), avisos=plan["avisos"],
detalle=f"el plan mueve {_plural(n, 'archivo', 'archivos')}: es mucho para hacerlo sin que lo mires")
return out
dec = {p["clave"]: p["defecto"] for p in asistente.pasos(asistente.diagnostico(raiz), plan["filas"])}
dec.update(convertir="no", respaldo="no", carpetas="seguras")
res = asistente.ejecutar(raiz, dec, plan["filas"])
out["movimientos"] = (res.get("movimientos") or 0) + (res.get("stock") or 0)
out["lotes"] = res.get("lotes") or []
out["errores"] = (res.get("errores") or [])[:20]
if asistente.ESTADO.get("error"):
out["errores"].append(asistente.ESTADO["error"])
despues = plan_en_marcha(raiz) or Plan(r).correr()
out["dudosos"] = sum(1 for f in despues["filas"] if not f["marcado"])
out["avisos"] = despues["avisos"][:20]
out["estado"] = "con errores" if out["errores"] else "con dudas" if out["dudosos"] else "al dia"
PLANES[despues["raiz"]] = despues # se queda: así Inicio dice por la mañana qué quedó, sin pedir que se calcule
return out
def automatico(en_hilo=False, origen="a mano"):
"""Todas las carpetas registradas, una tras otra. Deja el resumen en datos\\automatico.json."""
ocupado = en_marcha() or (COLA["activa"] and "hay una conversión en marcha") or \
(TRAER.get("estado") in ("copiando", "ordenando") and "se está trayendo material")
if AUTO["activo"] or ocupado:
return {"error": "ahora no: " + (ocupado or "el orden automático ya está en marcha")}
raices = [_clave(r["ruta"]) for r in CONFIG.get("raices", [])]
AUTO.update(activo=True, raiz="", hechas=0, total=len(raices), inicio=time.time())
if en_hilo:
threading.Thread(target=_automatico, args=(raices, origen, False), daemon=True).start()
return {"lanzado": True, "carpetas": len(raices)}
return _automatico(raices, origen, True)
def _automatico(raices, origen, sincrono=False):
limite = int((CONFIG.get("automatico") or {}).get("limite") or 3000)
resumen = {"ts": time.time(), "origen": origen, "version": VERSION, "carpetas": []}
try:
for raiz in raices:
AUTO["raiz"] = raiz
try:
resumen["carpetas"].append(automatico_una(raiz, limite))
except Exception as ex: # noqa
resumen["carpetas"].append({"raiz": raiz, "cliente": (raiz_cfg(raiz) or {}).get("cliente", ""), "ts": time.time(),
"estado": "con errores", "detalle": repr(ex), "movimientos": 0, "lotes": [],
"dudosos": 0, "avisos": [], "errores": [repr(ex)]})
AUTO["hechas"] += 1
try:
aligerar_automatico(resumen, sincrono=sincrono)
except Exception as ex: # noqa
resumen["aligerar"] = {"error": repr(ex)}
resumen["seg"] = round(time.time() - resumen["ts"], 1)
guardar_json(AUTOMATICO_PATH, resumen)
finally:
AUTO.update(activo=False, raiz="")
return resumen
def aligerar_automatico(resumen, sincrono=False):
"""Con optimizar.aligerar activado, la pasada mete en cola lo de más de N meses, de más pesado a menos,
hasta el tope de GB por noche, en todas las carpetas conectadas. Convierte y verifica; no reemplaza
(salvo que esté puesto auto_reemplazar). Si el programa corre sin ventana (la tarea de Windows), la cola
va aquí mismo, en este proceso; si no, arranca la cola de siempre."""
a = _opt("aligerar") or {}
if not a.get("activo"):
return
o = CONFIG["optimizar"]
meses, tope = int(a.get("meses") or 6), float(a.get("gb_noche") or 100) * 1e9
total_b, por_raiz = 0, {}
for r in CONFIG.get("raices", []):
raiz = _clave(r["ruta"])
if not os.path.isdir(raiz):
continue
AUTO["raiz"] = raiz
analizar(raiz, o["min_mb"]) # lee con ffprobe lo que falte, aquí mismo
for c in sorted(candidatos(raiz, meses, o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit")), key=lambda c: -c["bytes"]):
if c["excluido"] or c["en_cola"] or total_b + c["bytes"] > tope:
continue
total_b += c["bytes"]
por_raiz.setdefault(raiz, []).append(c["ruta"])
n = 0
for raiz, rutas in por_raiz.items():
n += len(encolar(rutas, o["perfil"], o.get("chroma", "420"), o.get("color", "rec709"), raiz, o.get("formato", "h265_10bit")) or [])
resumen["aligerar"] = {"clips": n, "bytes": total_b, "meses": meses, "tope_gb": a.get("gb_noche") or 100}
if not n:
return
if sincrono:
with _COLA_LOCK:
COLA.update(activa=True, cancelar=False, hechos=0, inicio=time.time(), ritmo=[], hechos_ids=[], esperando="")
bucle_cola()
else:
iniciar_cola()
def estado_automatico():
c = CONFIG.get("automatico") or {}
return {"activo": bool(c.get("activo")), "hora": c.get("hora") or "02:00", "limite": int(c.get("limite") or 3000),
"en_marcha": dict(AUTO), "ultimo": cargar_json(AUTOMATICO_PATH, None)}
def _orden_de_la_tarea():
"""Lo que tiene que lanzar Windows: este mismo programa con la orden «automatico»."""
import instalar
exe, args = instalar.orden(BASE)
return f'"{exe}" {args} automatico' if args else f'"{exe}" automatico'
def programar_automatico(activo, hora):
"""Da de alta (o de baja) en el Programador de tareas de Windows la pasada diaria. La tarea es del
usuario: corre con su sesión abierta, que es cuando están montados los discos del NAS."""
hora = (hora or "").strip()
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", hora):
return False, "la hora va como 02:00"
ensayo = os.environ.get("ORGANILAB_ENSAYO", "")
if ensayo: # las pruebas no tocan el Programador de tareas
guardar_json(os.path.join(ensayo, "tarea.json"), {"activo": bool(activo), "hora": hora, "orden": _orden_de_la_tarea()})
return True, ""
if activo:
cmd = ["schtasks", "/Create", "/F", "/TN", TAREA_WINDOWS, "/SC", "DAILY", "/ST", hora, "/TR", _orden_de_la_tarea()]
else:
cmd = ["schtasks", "/Delete", "/F", "/TN", TAREA_WINDOWS]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SIN_VENTANA)
except (OSError, subprocess.SubprocessError) as ex:
return False, str(ex)
if r.returncode != 0 and activo:
return False, (r.stderr or r.stdout or "Windows no aceptó la tarea").strip()
return True, ""
def configurar_automatico(activo, hora):
ok, error = programar_automatico(activo, hora or (CONFIG.get("automatico") or {}).get("hora") or "02:00")
if not ok:
return {"error": "no se pudo programar: " + error}
CONFIG.setdefault("automatico", {}).update(activo=bool(activo), hora=(hora or "02:00").strip())
guardar_json(CONFIG_PATH, CONFIG)
return {"ok": True, **estado_automatico()}
def automatico_por_comando():
"""Lo que lanza la tarea programada. Si el programa ya está abierto con estos datos, se lo pide a él
(dos procesos moviendo lo mismo se pisarían); si no, lo hace este mismo, sin ventana ni consola."""
if _soy_la_unica():
r = automatico(origen="programado")
return 1 if r.get("error") else 0
puerto = cargar_json(SESION_PATH, {}).get("puerto")
if not puerto:
return 1
import urllib.request
try:
rq = urllib.request.Request(f"http://127.0.0.1:{puerto}/api/automatico/ahora", b'{"origen": "programado"}',
{"Content-Type": "application/json"})
urllib.request.urlopen(rq, timeout=30).read()
return 0
except Exception: # noqa
return 1
_LOTES_OTROS = ("stock-", "dup-", "vacias-", "entrada-", "optimizar", "tanda-", "pieza-", "descartados-", "colores-", "respaldo")
def _ultimo_orden(raiz):
"""El último lote de orden (mover y reorganizar) aplicado sobre esta carpeta y no deshecho: cuándo y cuánto."""
ent = leer_bitacora(3000)
deshechos = {e.get("de_lote") for e in ent if e.get("op") == "deshacer"}
for e in reversed(ent):
l = e.get("lote") or ""
if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower() and l and not l.startswith(_LOTES_OTROS) and l not in deshechos:
return {"lote": l, "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])}
return None
def _cuando(ts):
"""«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40»."""
if not ts:
return ""
t = time.localtime(ts)
hoy = time.localtime()
hora = time.strftime("%H:%M", t)
if t[:3] == hoy[:3]:
return f"hoy a las {hora}"
if t[:3] == time.localtime(time.time() - 86400)[:3]:
return f"ayer a las {hora}"
return f"el {t.tm_mday} de {R.MESES[t.tm_mon - 1]}" + (f" de {t.tm_year}" if t.tm_year != hoy.tm_year else "") + f" a las {hora}"
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]
# --- 0. el inventario es de una versión que reconocía peor las cámaras y las fechas ---
if (esc["reglas"] or 0) < R.VERSION_REGLAS:
añadir(clave="escanear", titulo="Escanear otra vez", boton="Escanear",
que="Esta versión reconoce mejor las cámaras, los clips ya convertidos y el año de cada rodaje.",
cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.",
ir="escanear")
# --- la bandeja de entrada: material nuevo esperando a que lo repartan ---
ent = entrada(raiz)
if ent["n"]:
añadir(clave="entrada", titulo="Repartir lo que hay en la bandeja de entrada", boton="Repartir",
que=f"Material nuevo en «{R.ENTRADA}»: va a su mes, a su día de rodaje y a su cámara.",
cifra=f"{_plural(ent['n'], 'archivo', 'archivos')} · {_gb(ent['bytes'])}",
detalle="La app vuelve a leer la carpeta y mueve lo que da por seguro; lo dudoso se queda en la bandeja.",
ir="entrada")
# --- 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()
vieja = con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.join(raiz, R.PAPELERAS[1]),)).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")
elif vieja:
añadir(clave="limpiar", titulo="Juntar las dos papeleras", boton="Ver y juntar",
que=f"Lo apartado está repartido entre «{R.PAPELERAS[1]}», el nombre de antes, y «{R.PAPELERA}».",
cifra=f"{_plural(vieja['n'] or 0, 'archivo', 'archivos')} · {_gb(vieja['bytes'] or 0)}",
detalle=f"Todo pasa a «{R.PAPELERA}». No se borra nada.",
ir="plan", paso="limpiar")
else:
# lo ya apartado se dice, para que se vea que el cambio se hizo (antes la ficha quedaba muda)
apartado = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? AND ("
+ " OR ".join(["ruta LIKE ? ESCAPE '!'"] * len(R.PAPELERAS)) + ")", [raiz] + args_pap).fetchone()
añadir(clave="limpiar", titulo="Basura y proxies", estado="hecho",
que="No queda nada suelto que apartar.", cifra="",
detalle=f"{_plural(apartado['n'], 'archivo', 'archivos')} ya en «{R.PAPELERA}» ({_gb(apartado['b'])}): se borran cuando tú quieras." if apartado["n"] else "")
# --- 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)
ultimo = _ultimo_orden(raiz)
hecho_det = f"Hecho {_cuando(ultimo['ts'])}: {_plural(ultimo['movimientos'], 'movimiento', 'movimientos')}." if ultimo else ""
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 and not marc:
# solo queda lo que la app no mueve sola: no es una tarea pendiente, es algo que mirar.
# Si contara como pendiente, la carpeta no llegaría nunca a estar «al día».
añadir(clave=clave, titulo=titulo, boton="Ver", que=que, estado="espera",
cifra=_plural(len(fs), "dudoso", "dudosos"),
detalle=(hecho_det + " " if hecho_det else "Lo seguro ya está hecho. ")
+ "Queda lo que la app no se atreve a mover sola: míralo y marca lo que quieras.", ir="plan", paso=clave)
elif 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=hecho_det)
elif REPLAN.get(raiz):
# se acaba de mover algo y el plan se está volviendo a pensar: en un momento se sabe qué queda
añadir(clave=clave, titulo=titulo, que=que, cifra="", estado="espera", calculando=True,
detalle=(hecho_det + " " if hecho_det else "") + "Comprobando qué queda…", ir="plan", paso=clave)
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=(hecho_det + " " if hecho_det else "") + "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 ---
# lo que ya está apartado en una carpeta «Duplicados» no cuenta: si contara, esto no acabaría nunca
sin_dup = " AND instr(lower(ruta), ?) = 0"
arg_dup = [os.sep + R.DUPLICADOS.lower() + os.sep]
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 + sin_dup + " GROUP BY nombre, bytes HAVING COUNT(*) > 1)",
[raiz] + args_pap + arg_dup).fetchone()
# el mismo clip con otra calidad: se llama igual y pesa distinto. Cuenta lo que aún no se ha leído y
# lo que, ya leído, dura lo mismo; dos clips que se llaman igual y duran distinto son clips distintos.
por_nombre = defaultdict(list)
for f in con.execute(
"SELECT lower(nombre) nm, bytes, dur, analizado FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 "
"AND bytes > 5000000 AND rol IN ('camara_raw','camara_video','celular_video') AND ext IN (%s)"
% ",".join("?" * len(_EXT_VIDEO_DUP)) + en_papelera + sin_dup,
[raiz] + sorted(_EXT_VIDEO_DUP) + args_pap + arg_dup).fetchall():
if f["analizado"] != 2: # 2 = no se pudo leer: no hay con qué comparar
por_nombre[f["nm"]].append(f)
por_comparar = 0
for fs in por_nombre.values():
if len(fs) < 2 or len({f["bytes"] for f in fs}) < 2:
continue
if any(f["analizado"] != 1 or not f["dur"] for f in fs):
por_comparar += len(fs) - 1
else:
durs = sorted(f["dur"] for f in fs)
por_comparar += sum(1 for a, b in zip(durs, durs[1:]) if b - a <= max(0.5, 0.01 * a))
if rep_fila["n"] or por_comparar:
partes = ([f"{_plural(rep_fila['n'], 'copia de más', 'copias de más')} · {_gb(rep_fila['b'])}"] if rep_fila["n"] else []) \
+ ([f"{_plural(por_comparar, 'clip', 'clips')} con otra calidad por comparar"] if por_comparar else [])
añadir(clave="repetidos", titulo="Juntar material repetido", boton="Ver repetidos",
que="Mismo nombre y mismo peso en varias carpetas, o el mismo clip guardado con dos calidades.",
cifra=" · ".join(partes),
detalle="Se comprueba el contenido antes de tocar nada; la copia buena se queda donde está y la otra va a «Duplicados» a su lado.",
ir="repetidos")
# --- 7. convertir clips viejos ---
o = CONFIG["optimizar"]
# los clips que ya salieron del convertidor siguen siendo de cámara, pero no están por aligerar
por_aligerar = [f for f in con.execute(
"SELECT nombre, bytes, analizado 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).fetchall() if not R.es_conversion(f["nombre"])]
cam = {"n": len(por_aligerar), "b": sum(f["bytes"] or 0 for f in por_aligerar),
"ana": sum(1 for f in por_aligerar if f["analizado"] == 1)}
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:
# ya leídos: cuentan solo los que de verdad adelgazan (ni conversiones, ni lo que ya viene comprimido)
cands = [c for c in candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit"))
if not c["excluido"] and not c["en_cola"] and c["rol"] in ("camara_raw", "camara_video")]
sin_leer = cam["n"] - cam["ana"]
if cands:
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips",
que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.",
cifra=f"{_plural(len(cands), 'clip', 'clips')} · {_gb(sum(c['bytes'] for c in cands))} → unos {_gb(sum(c['estimado'] for c in cands))}",
detalle=(f"{sum(1 for c in cands if c['motor'] != 'ffmpeg')} son BRAW. " if any(c["motor"] != "ffmpeg" for c in cands) else "")
+ (f"{hechos} ya reemplazados." if hechos else "El resto ya viene comprimido o es reciente."),
ir="convertir")
elif sin_leer > 0:
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips",
que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.",
cifra=f"{_plural(sin_leer, 'clip', 'clips')} por leer",
detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.",
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")]
if (esc["reglas"] or 0) < R.VERSION_REGLAS:
auto = [] # con un inventario viejo no se hace nada «de una vez»: primero se relee
return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc), "ultimo": ultimo,
"calculando": REPLAN.get(raiz) is not None,
"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 ServidorLocal(ThreadingHTTPServer):
"""El servidor de la interfaz, con el puerto para él solo. ThreadingHTTPServer pide «reutilizar dirección»,
y en Windows eso significa que dos programas pueden escuchar a la vez en el mismo puerto y las peticiones
van a cualquiera de los dos: dos OrganiLAB con datos distintos (el instalado y una copia suelta, o el de
ensayo y el de verdad) se contestaban el uno por el otro sin que nada fallara a la vista."""
allow_reuse_address = False
def server_bind(self):
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
super().server_bind()
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):
ULTIMA_PETICION[0] = time.time()
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/aplicando": # la barra de «Aplicar», sondeada mientras corre
return self._json(dict(APLICANDO))
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_en_marcha(r["ruta"]) or 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") == "mkdir": # una pieza nueva: cuentan las carpetas creadas
lo["movimientos"] += 1
if e.get("op") == "tanda": # una tanda del convertidor: su resumen
lo["raiz"] = e.get("raiz", "") or ", ".join(e.get("raices") or [])
lo["movimientos"] = e.get("clips", 0)
lo["errores"] = e.get("fallos", 0)
lo["tanda"] = {k: e.get(k) for k in ("clips", "bytes_in", "bytes_out", "seg", "nota", "reemplazados", "descartados", "cliente")}
if e.get("op") == "fin":
lo["raiz"] = e.get("raiz", "")
lo["errores"] = len(e.get("errores") or [])
if l.startswith("entrada-"): # lo traído de una tarjeta: copias, no movimientos
lo["movimientos"] = e.get("movimientos", 0)
if e.get("origen") and e.get("destino"):
lo["ejemplos"].append({"de": e["origen"], "a": e["destino"]})
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()
# con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar»
filas = [dict(r) for r in con.execute("SELECT t.*, a.dur AS dur, a.fps AS fps FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
"ORDER BY t.id DESC LIMIT 500").fetchall()]
for f in filas: # un original ya reemplazado vive en _Para borrar/originales: su ficha está ahí
if f.get("dur") is None and f["estado"] == "reemplazado":
otra = _ruta_original(f)
a = con.execute("SELECT dur, fps FROM archivos WHERE ruta = ?", (otra,)).fetchone() if otra else None
if a:
f["dur"], f["fps"] = a["dur"], a["fps"]
con.close()
try:
eta = eta_cola(filas) if COLA["activa"] else None
except Exception: # noqa
eta = None
if COLA.get("ultima") is None:
COLA["ultima"] = _ultima_tanda_guardada()
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta,
"esperando": COLA.get("esperando") or "", "ultima": COLA.get("ultima") or None}})
if ruta == "/api/optimizar/tanda.csv": # la tanda, en CSV
csv_ = tanda_csv(q.get("lote") or "")
if csv_ is None:
return self._json({"error": "no hay una tanda con ese nombre"}, 404)
cuerpo = csv_.encode("utf-8-sig")
self.send_response(200)
self.send_header("Content-Type", "text/csv; charset=utf-8")
self.send_header("Content-Disposition", f"attachment; filename=\"OrganiLAB-{q.get('lote') or 'tanda'}.csv\"")
self.send_header("Content-Length", str(len(cuerpo)))
self.end_headers()
self.wfile.write(cuerpo)
return
if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar»
try:
jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0))
except (ValueError, TypeError) as ex:
return self._json({"error": str(ex)}, 400)
with open(jpg, "rb") as fh:
cuerpo = fh.read()
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(cuerpo)))
self.send_header("Cache-Control", "private, max-age=86400")
self.end_headers()
self.wfile.write(cuerpo)
return
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"]), calidades=q.get("calidades") == "1"))
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", ""))})
if ruta == "/api/entrada":
return self._json(entrada(q["raiz"]))
if ruta == "/api/automatico":
return self._json(estado_automatico())
if ruta == "/api/pieza/previa":
return self._json(nueva_pieza(q["raiz"], q.get("titulo", ""), q.get("anio"), q.get("mes"), crear=False))
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):
ULTIMA_PETICION[0] = time.time()
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:
que = "Elige la tarjeta o la carpeta con el material nuevo" if d.get("para") == "traer" else "Elige la carpeta del cliente"
return self._json({"ruta": elegir_carpeta(que)})
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", "auto_reemplazar",
"horario", "pausar_si_edito", "aligerar", "avisar"},
"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
_sufijos_conversion()
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)
if cub and tok in R.NOMBRES_DE_LA_APP:
return self._json({"error": f"«{d.get('token')}» es una carpeta de la propia app: no hay nada que enseñarle"}, 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:
# el plan se olvidó porque la carpeta cambió (se movió algo, se volvió a escanear): los
# números de fila que trae la interfaz ya no dicen nada; se pide calcularlo otra vez
if PLAN_GEN.get(raiz):
return self._json({"error": "la carpeta cambió desde que se calculó el plan: hay que volver a calcular «Qué va a pasar»", "recalcular": True}, 409)
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)
if not res.get("movimientos"):
olvidar_plan(raiz) # si algo se movió, ya lo olvidó el inventario
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)
if ((acciones(raiz).get("escaneo") or {}).get("reglas") or 0) < R.VERSION_REGLAS:
return self._json({"error": "el inventario de esta carpeta es de una versión anterior: escanéala otra vez antes"}, 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":
ap = apartar_descartados(d.get("ids", []))
ids = [i for i in d.get("ids", []) if i not in ap["quedan"]]
con = db()
with con:
con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado','descartado') AND id IN ({','.join('?' * len(ids))})", ids)
con.close()
return self._json({"ok": True, "apartados": ap})
if acc == "primero": # «primero este»: pasa delante de los que esperan
con = db()
with con:
con.execute("UPDATE trabajos SET orden = (SELECT COALESCE(MAX(orden), 0) + 1 FROM trabajos) WHERE id = ? AND estado = 'pendiente'", (int(d.get("id") or 0),))
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/calidad": # la nota VMAF de un convertido: tarda unos segundos
try:
return self._json(calidad_de(int(d.get("id") or 0)))
except (ValueError, TypeError, subprocess.TimeoutExpired) as ex:
return self._json({"error": str(ex)}, 400)
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/pieza":
r = nueva_pieza(d["raiz"], d.get("titulo", ""), d.get("anio"), d.get("mes"))
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/entrada/abrir":
r = abrir_entrada(d["raiz"])
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/entrada/ordenar":
r = ordenar_entrada(d["raiz"])
return self._json(r, 409 if r.get("error") else 200)
if ruta == "/api/entrada/plan":
r = plan_traer(d["raiz"], d.get("origen", ""), d.get("nombre", ""))
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/entrada/traer":
r = traer(d["raiz"], d.get("origen", ""), d.get("nombre", ""), d.get("ordenar", True) is not False, en_hilo=True)
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/entrada/cancelar":
if TRAER.get("estado") == "copiando":
TRAER["cancelar"] = True
return self._json({"ok": True})
if ruta == "/api/automatico/config":
r = configurar_automatico(bool(d.get("activo")), d.get("hora"))
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/automatico/ahora":
r = automatico(en_hilo=True, origen=d.get("origen") or "a mano")
return self._json(r, 409 if r.get("error") else 200)
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 OrganiLAB en su ventana
OrganizadorLabstream.exe instalar lo instala en su sitio: carpeta de programas, menú Inicio y Escritorio (desinstalar lo quita)
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
OrganizadorLabstream.exe automatico pone al día todas las carpetas registradas: lee cada una y hace solo lo seguro
(es lo que lanza Windows cada día si se enciende el orden automático)
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": []}
SESION_PATH = os.path.join(DATOS, "sesion.json") # qué copia tiene abiertos estos datos, y en qué puerto atiende
_CANDADO = [] # el de «una sola copia abierta»; vive lo que el proceso
SILENCIO_NAVEGADOR = 180 # segundos sin que la página pregunte para dar el navegador por cerrado
RELEVO = [] # el .exe que toma el relevo al salir (tras instalarse en su sitio)
def _al_soltar(rutas):
"""Lo que se acaba de soltar en la ventana, con su ruta completa: la ventana la sabe, la interfaz no."""
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. Espera un poco porque el aviso de la ventana
y la pregunta de la interfaz no llegan siempre 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 en_marcha():
"""Qué está haciendo el programa ahora mismo, dicho en llano, o "" si no hace nada. Mientras haya algo,
cerrar la ventana no lo corta."""
que = actualizador.tarea_en_marcha()
if not que and APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
que = "se están moviendo archivos"
if not que and TRAER.get("estado") in ("copiando", "ordenando"):
que = "se está trayendo material de una tarjeta"
if not que and AUTO.get("activo"):
que = "el orden automático está poniendo al día las carpetas"
return que
def avance():
"""Cuánto lleva lo que está en marcha: de 0 a 1, -1 si no se puede saber y None si no hay nada. Es lo que
se ve en el botón de la barra de tareas."""
if COLA["activa"]:
try:
con = db()
espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0]
t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None
con.close()
except sqlite3.Error:
return -1
total = COLA["hechos"] + (1 if t else 0) + espera
return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1
if RESPALDO.get("estado") == "corriendo":
return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1
if TRAER.get("estado") == "copiando":
return TRAER["bytes"] / TRAER["bytes_total"] if TRAER.get("bytes_total") else -1
if AUTO.get("activo"):
return AUTO["hechas"] / AUTO["total"] if AUTO.get("total") else -1
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
return APLICANDO["unidades"] / APLICANDO["unidades_total"] if APLICANDO.get("unidades_total") else APLICANDO["hechas"] / APLICANDO["total"]
if asistente.ESTADO.get("activo"):
return asistente.ESTADO["pct"] / 100 if asistente.ESTADO.get("total") else -1
for a in list(ANALISIS.values()):
if (a or {}).get("estado") == "corriendo":
return a["hechos"] / a["total"] if a.get("total") else -1
if any((p or {}).get("estado") == "corriendo" for p in list(PROGRESO.values())):
return -1
if actualizador.ESTADO.get("estado") == "descargando":
return (actualizador.ESTADO.get("pct") or 0) / 100
return None
def _marca():
"""Un número que identifica esta instalación por su carpeta de datos."""
return (zlib.crc32(os.path.normcase(os.path.abspath(DATOS)).encode("utf-8")) & 0x7FFFFFFF) or 1
def _soy_la_unica():
"""False si ya hay otra copia del programa abierta sobre estos mismos datos: dos a la vez se pisarían el
inventario, y la segunda daría por huérfanas las conversiones que la primera tiene en marcha."""
if os.name != "nt":
return True
k = ctypes.WinDLL("kernel32", use_last_error=True)
k.CreateMutexW.argtypes, k.CreateMutexW.restype = [ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p], ctypes.c_void_p
h = k.CreateMutexW(None, False, f"OrganiLAB-{_marca():08x}")
if h and ctypes.get_last_error() == 183: # ya existía
return False
_CANDADO.append(h)
return True
def _limpiar_restos():
"""Lo que dejó el .exe de PyInstaller de las versiones hasta la 2.x: su carpeta _internal, que ya no usa
nadie (el motor de ahora va en la carpeta runtime)."""
viejo = os.path.join(BASE, "_internal")
if CONGELADO and not getattr(sys, "frozen", False) and os.path.isfile(os.path.join(viejo, "base_library.zip")):
shutil.rmtree(viejo, ignore_errors=True)
def _vigilar(v, fin):
"""Mientras la ventana existe: lleva el avance al botón de la barra de tareas, avisa cuando termina algo
largo y, si la ventana se cerró con trabajo pendiente, cierra el programa en cuanto acaba."""
antes, desde = None, 0.0
while not fin.wait(1.0):
try:
f = avance()
except Exception: # noqa
f = None
if f is None:
if antes is not None:
v.progreso(None)
if time.time() - desde > 20 and not v.oculta:
v.avisar()
if v.oculta and not en_marcha():
v.cerrar()
else:
if antes is None:
desde = time.time()
if not v.oculta:
v.progreso(f)
antes = f
def _recien_abierta(v):
"""Con la interfaz ya a la vista: si el programa está dado de alta en Windows, pone al día su versión y
sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece instalarlo: copiarlo a
su sitio definitivo, darlo de alta y seguir desde allí."""
try:
import ventana
import instalar
if instalar.al_dia(BASE, VERSION):
return
oferta = CONFIG.get("instalacion") or {}
if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE):
return
if instalar.instalado() and not instalar.instalado_aqui(BASE):
return # ya hay otro instalado en este equipo: esta copia va por libre
CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez
guardar_json(CONFIG_PATH, CONFIG)
time.sleep(1.5)
if not instalar.hay_que_mudar(BASE): # ya está en su sitio: solo falta darlo de alta
if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n"
"Se quita desde Configuración › Aplicaciones.", ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES:
instalar.instalar(BASE, VERSION)
v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.")
return
if v.mensaje("¿Instalar OrganiLAB en este equipo?\n\nSe copia a tu carpeta de programas, queda en el menú Inicio y en el "
"Escritorio, y se quita desde Configuración › Aplicaciones. La carpeta desde la que lo has abierto ya no hará falta.",
ventana.MB_YESNO | ventana.MB_ICONQUESTION) != ventana.IDYES:
return
v.evaluar("modal(" + json.dumps("Instalando OrganiLAB…") + ", " + json.dumps(
'<p><span class="spin"></span> Copiando el programa a su sitio definitivo. Tarda unos segundos; al terminar se abre solo desde allí.</p>') + ", null)")
try:
r = instalar.poner(BASE, VERSION, avance=v.progreso)
except Exception as ex: # noqa
v.progreso(None)
v.evaluar("document.querySelector('#modal').classList.remove('on')")
v.mensaje(f"No se pudo instalar: {ex}\n\nEl programa sigue funcionando desde esta carpeta.", ventana.MB_OK | ventana.MB_ICONWARNING)
return
v.progreso(None)
v.mensaje("OrganiLAB quedó instalado.\n\nAhora se cierra y se abre desde su sitio. La carpeta desde la que lo abriste "
"ya no hace falta: puedes borrarla.")
RELEVO.append(os.path.join(r["base"], "OrganizadorLabstream.exe"))
v.cerrar()
except Exception as ex: # noqa
print("no se pudo dar de alta en Windows:", ex)
def _ventana(url):
"""Abre la ventana del programa y no vuelve hasta que se cierra. Devuelve False si este Windows no puede
abrirla (le falta WebView2): quien llama abre entonces la interfaz en el navegador."""
global VENTANA
try:
import ventana
import instalar
ok, detalle = ventana.disponible(BASE)
if not ok:
print("Sin ventana propia:", detalle)
return False
v = ventana.Ventana(url, titulo="OrganiLAB", icono=os.path.join(BASE, "static", "organilab.ico"),
perfil=os.path.join(DATOS, "ventana"), memoria=os.path.join(DATOS, "ventana.json"),
base=BASE, marca=_marca(), relanzar=instalar.orden(BASE),
depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR")))
v.al_cerrar, v.al_soltar = en_marcha, _al_soltar
v.al_abrir = lambda: _recien_abierta(v)
fin = threading.Event()
threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start()
VENTANA = v
try:
abierta = v.mostrar()
finally:
VENTANA = None
fin.set()
if not abierta:
print("La ventana no llegó a abrirse:", v.fallo)
return abierta
except Exception as ex: # noqa
print("Sin ventana propia:", ex)
return False
def _atender_navegador():
"""Sin ventana no hay nada que cerrar: el programa sigue mientras la página del navegador pregunte (lo hace
cada pocos segundos) y se va cuando lleva tres minutos callada y no queda trabajo en marcha."""
ULTIMA_PETICION[0] = time.time()
try:
while time.time() - ULTIMA_PETICION[0] < SILENCIO_NAVEGADOR or en_marcha():
time.sleep(2)
except KeyboardInterrupt:
pass
def main():
global PUERTO
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")]
if CONGELADO and "--puente" not in sys.argv:
import instalar
instalada = instalar.mudado(BASE)
if instalada: # desde esta carpeta ya se instaló el programa: se abre el instalado
if sys.argv[1:]:
sys.exit(subprocess.call([instalada] + sys.argv[1:]))
subprocess.Popen([instalada], cwd=os.path.dirname(instalada), close_fds=True)
return
if args_tmp and args_tmp[0].lower() in ("instalar", "desinstalar") and "--puente" not in sys.argv:
import instalar # hablan con cuadros de mensaje: no hace falta consola
if args_tmp[0].lower() == "instalar" and instalar.hay_que_mudar(BASE) and not _soy_la_unica():
import ventana # la mudanza se lleva los datos: con el programa abierto se quedarían a medias
ctypes.windll.user32.MessageBoxW(None, "Cierra OrganiLAB antes de instalarlo.", "OrganiLAB", ventana.MB_OK | ventana.MB_ICONINFORMATION)
sys.exit(1)
sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv))
if args_tmp and args_tmp[0].lower() == "automatico" and "--puente" not in sys.argv:
sys.exit(automatico_por_comando()) # lo lanza la tarea programada: sin consola ni ventana
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: # puente con Resolve, como lo llamaban los .exe hasta la 2.x
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 CONFIG.get("actualizaciones", {}).get("auto") is True
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)
sin_ventana = "--sin-navegador" in sys.argv or "--navegador" in sys.argv
if not _soy_la_unica():
import ventana
if not sin_ventana and ventana.traer_al_frente(_marca()):
return # ya estaba abierto: se enseña esa ventana y nada más
otra = cargar_json(SESION_PATH, {}).get("puerto")
print("Ya hay un OrganiLAB abierto con estos datos" + (f", en el puerto {otra}." if otra else "."))
if otra and "--sin-navegador" not in sys.argv:
webbrowser.open(f"http://127.0.0.1:{otra}/") # esa copia no tiene ventana: va en el navegador
return
h = herramientas()
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
print("BRAW:", "se leen con el decodificador de Blackmagic" if h["braw"]["ok"]
else "falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve; solo hace falta para BRAW)")
for resto in os.listdir(DATOS): # fichas de trabajo que dejó una conversión cortada (o el guion de Resolve, que no las borraba)
if resto.endswith((".json", ".cancelar")) and resto.startswith(("resolve_lote_", "braw_")):
try:
os.remove(os.path.join(DATOS, resto))
except OSError:
pass
huerfanas = recuperar_huerfanas()
threading.Thread(target=lambda: (repasar_verificados(), braws_al_lector()), daemon=True).start()
if huerfanas:
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
try:
srv = ServidorLocal(("127.0.0.1", PUERTO), Manejador)
except OSError:
if os.environ.get("ORGANIZADOR_PUERTO"):
print(f"El puerto {PUERTO} está ocupado por otro programa.")
sys.exit(1)
# el puerto de siempre lo tiene otro programa (u otro OrganiLAB con otros datos): se toma uno libre,
# que a la ventana le da igual cuál sea
srv = ServidorLocal(("127.0.0.1", 0), Manejador)
PUERTO = srv.server_address[1]
url = f"http://127.0.0.1:{PUERTO}/"
guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": PUERTO})
if sys.stdout:
print(f"OrganiLAB {VERSION} en {url}")
if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True:
actualizador.comprobar_en_segundo_plano()
try:
if "--sin-navegador" in sys.argv: # solo el servidor (pruebas y desarrollo)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return
hilo = threading.Thread(target=srv.serve_forever, daemon=True)
hilo.start()
threading.Thread(target=_limpiar_restos, daemon=True).start()
if "--navegador" in sys.argv:
webbrowser.open(url)
try:
hilo.join()
except KeyboardInterrupt:
pass
return
if not _ventana(url):
webbrowser.open(url)
_atender_navegador()
while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir
time.sleep(1)
srv.shutdown()
srv.server_close()
if RELEVO: # se acaba de instalar en su sitio: sigue desde allí
subprocess.Popen([RELEVO[0]], cwd=os.path.dirname(RELEVO[0]), close_fds=True)
finally:
try:
os.remove(SESION_PATH)
except OSError:
pass
if __name__ == "__main__":
main()