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