Medido en la RTX 5080 del estudio con la cola de Vía Sumapaz (1.920 clips, unas 26 horas): el codificador iba al 46-74 % y el descompresor al 0 %. Con NVDEC un H.264 4K pasa de 0,54× a 0,86× tiempo real; dos a la vez salen a 1,29× entre los dos. Además: al arrancar se cierran las conversiones que quedaron sin su programa (una de las 10:04 seguía colgada a las 17:00), «Detener» cierra el ffmpeg del lector de BRAW, la cola se pide entera por carpeta (antes, los 500 últimos de todas), y lo encolado que ya no compensa con la regla del 30 % no se convierte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8604 líneas
452 KiB
Python
8604 líneas
452 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Organizador Labstream — inventario, mapa, orden y optimización del material.
|
||
|
||
Solo librería estándar de Python (3.10+). Es un programa de Windows: abre su
|
||
propia ventana (ventana.py) y la interfaz habla con este módulo por un servidor
|
||
que solo escucha en este equipo.
|
||
|
||
OrganizadorLabstream.exe (el paquete: lo arma empaquetar.py)
|
||
py -3.12 organizador.py (desde el código)
|
||
|
||
Nunca borra nada: lo que sobra va a _Para borrar dentro de la misma raíz y cada
|
||
movimiento queda en datos/bitacora.jsonl para poder deshacerlo.
|
||
"""
|
||
import os
|
||
import sys
|
||
import re
|
||
import json
|
||
import zlib
|
||
import struct
|
||
import ctypes
|
||
import time
|
||
import queue
|
||
import shutil
|
||
import socket
|
||
import stat as stat_mod
|
||
import hashlib
|
||
import sqlite3
|
||
import threading
|
||
import subprocess
|
||
import tempfile
|
||
import datetime
|
||
import webbrowser
|
||
from collections import Counter, defaultdict
|
||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||
from urllib.parse import urlparse, parse_qs
|
||
|
||
import reglas as R
|
||
import braw
|
||
import asistente
|
||
import actualizador
|
||
asistente.O = sys.modules[__name__]
|
||
actualizador.O = sys.modules[__name__]
|
||
|
||
VERSION = "3.9.0"
|
||
|
||
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
|
||
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
|
||
# el intérprete de Python tal como lo firma la Python Software Foundation, con otro nombre (arranque.py
|
||
# cuenta cómo). Hasta la 2.x era un .exe de PyInstaller, sin firma, y Smart App Control lo bloqueaba.
|
||
CONGELADO = bool(getattr(sys, "frozen", False)) or os.path.normcase(os.path.dirname(os.path.abspath(sys.executable))) == os.path.normcase(BASE)
|
||
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras
|
||
DATOS = os.path.join(BASE, "datos")
|
||
os.makedirs(DATOS, exist_ok=True)
|
||
DB_PATH = os.path.join(DATOS, "inventario.sqlite")
|
||
CONFIG_PATH = os.path.join(DATOS, "config.json")
|
||
DICC_PATH = os.path.join(DATOS, "diccionario.json")
|
||
BITACORA_PATH = os.path.join(DATOS, "bitacora.jsonl")
|
||
PLANTILLAS_PATH = os.path.join(DATOS, "plantillas.json")
|
||
PUERTO = int(os.environ.get("ORGANIZADOR_PUERTO", "8765"))
|
||
|
||
LOCK = threading.RLock()
|
||
|
||
# ==========================================================================
|
||
# Configuración y diccionario (lo que la app aprende)
|
||
# ==========================================================================
|
||
|
||
CONFIG_DEFECTO = {
|
||
# ninguna carpeta puesta de fábrica: la primera vez el programa abre en blanco y las carpetas
|
||
# entran cuando alguien las suelta. Lo que sí viene de serie es el diccionario de clientes y
|
||
# discos del estudio, que es conocimiento, no estado.
|
||
"raices": [],
|
||
"clientes": {
|
||
"David Reyes": {"tipo": "contenido", "alias": ["DR. DAVID REYES", "David Reyes", "Davids", "Dr David Reyes"]},
|
||
"Danney Gómez": {"tipo": "contenido", "alias": ["Danney", "DRA. DANNEY GOMEZ", "Dra_Danney", "Dra Danney", "Skincenter"]},
|
||
"Camilo Ortega": {"tipo": "pauta", "alias": ["Camilo Ortega", "Camilo", "Camilo Ortega Anuncios", "Camilo Anuncio", "VSL Camilo Ortega", "Apoyos Camilo", "Anuncios Jonathan"]},
|
||
"Andrea Galeano": {"tipo": "contenido", "alias": ["DRA. ANDREA GALEANO", "Galeano", "Portadas Galeano"]},
|
||
"Carolina Solórzano": {"tipo": "contenido", "alias": ["DRA. CAROLINA SOLORZANO", "Solorzano"]},
|
||
"Diana Méndez": {"tipo": "contenido", "alias": ["DRA. DIANA MENDEZ", "Diana Mendez", "DRA. DIANA MENDEZ (1)"]},
|
||
"Luisa Báez": {"tipo": "contenido", "alias": ["DRA. LUISA BAEZ", "Luisa Baez"]},
|
||
"Malpraxis": {"tipo": "pauta", "alias": ["Malpraxis", "Malpraxiss", "Malpraxis 2026", "App Malpraxis - Hotjamin"]},
|
||
"Brigard & Urrutia": {"tipo": "proyecto", "alias": ["Brigard & Urrutia", "Brigard Urrutia", "Brigard", "Brigard 2025", "Brigard 2026"]},
|
||
"PepsiCo": {"tipo": "proyecto", "alias": ["PepsiCo", "pepsico"]},
|
||
"Kingspan / Synthesia": {"tipo": "proyecto", "alias": ["Kingspam", "Kingspan", "Sinthesia", "Syntesia", "SYNTHESIA - KINGSPAN"]},
|
||
"Filarmónica de Bogotá": {"tipo": "proyecto", "alias": ["FILARMONICA DE BOGOTA", "Filarmónica de Bogotá", "Filarmonica"]},
|
||
"Fundación Vascular": {"tipo": "proyecto", "alias": ["CLINICA FUNDACIÓN VASCULAR", "Fundación Vascular"]},
|
||
"Legal Core": {"tipo": "proyecto", "alias": ["LEGAL CORE", "Legal Core"]},
|
||
"Vía Sumapaz": {"tipo": "proyecto", "alias": ["Vía SUMAPAZ", "VÍA SUMAPAZ", "Sumapaz", "Páramo Sumapaz lucy"]},
|
||
"Latinología": {"tipo": "proyecto", "alias": ["Latinología"]},
|
||
"CICR": {"tipo": "proyecto", "alias": ["CICR"]},
|
||
"Andrés Arango": {"tipo": "proyecto", "alias": ["Andres Arango"]},
|
||
"Labstream (interno)": {"tipo": "interno", "alias": ["LABSTREAM", "LABSTREAM ACADEMY", "Labstream Medicina", "producción", "Recursos audiovisuales", "Recursos Audiovisuales", "Sonidos"]},
|
||
},
|
||
"discos": {
|
||
"Z:": {"nombre": "labstream · NAS Labstream", "nivel": "trabajo"},
|
||
"W:": {"nombre": "Dra_Danney · Cloud Store Mini (10G)", "nivel": "caliente"},
|
||
"N:": {"nombre": "Camilo Ortega · NAS Labstream", "nivel": "trabajo"},
|
||
"K:": {"nombre": "Entregas_LAB · LabTem (lo maneja Labstream OS)", "nivel": "entregas"},
|
||
"Q:": {"nombre": "Exportados · NAS Labstream (lo maneja Labstream OS)", "nivel": "entregas"},
|
||
"T:": {"nombre": "temple · LabTem", "nivel": "frio"},
|
||
"M:": {"nombre": "Malpraxis · NAS Labstream", "nivel": "trabajo"},
|
||
"X:": {"nombre": "Datos · NAS Labstream", "nivel": "frio"},
|
||
"Y:": {"nombre": "Nubes · NAS Labstream", "nivel": "frio"},
|
||
"V:": {"nombre": "Medicina1 · NAS Labstream", "nivel": "trabajo"},
|
||
"U:": {"nombre": "Reels · LabTem", "nivel": "trabajo"},
|
||
"L:": {"nombre": "Apoyos Camilo · LabTem", "nivel": "trabajo"},
|
||
"R:": {"nombre": "Recursos Audiovisuales · LabTem", "nivel": "recursos"},
|
||
"S:": {"nombre": "Sonidos · LabTem", "nivel": "recursos"},
|
||
"O:": {"nombre": "Operaciones_LAB · NAS Labstream", "nivel": "sistema"},
|
||
},
|
||
"optimizar": {
|
||
"meses_minimos": 3,
|
||
"min_mb": 300,
|
||
"perfil": "equilibrado",
|
||
"formato": "h265_10bit",
|
||
"chroma": "420",
|
||
"color": "rec709",
|
||
"salida": "junto", # junto = al lado del original con sufijo _10bit ; espejo = _Archivo/<ruta>
|
||
"sufijo": "_10bit",
|
||
"ffmpeg": "ffmpeg",
|
||
"ffprobe": "ffprobe",
|
||
"python_resolve": "", # vacío = el mismo intérprete
|
||
"braw": "", # carpeta del decodificador de Blackmagic RAW; vacío = se busca sola
|
||
"ahorro_minimo": 30, # % que tiene que ahorrar un clip convertido para que se ofrezca
|
||
},
|
||
# las subcarpetas con las que nace una pieza nueva (las que más se repiten en las piezas del estudio)
|
||
"pieza": {"subcarpetas": ["Guion", "Videos", "Imagenes", "Musica", "Elements", "IA", "Portada", "Exportado"]},
|
||
# el orden automático viene apagado: lo enciende una persona, y entonces Windows lanza la pasada a esa hora.
|
||
# «limite» es cuántos archivos puede mover de una carpeta sin que nadie lo mire.
|
||
"automatico": {"activo": False, "hora": "02:00", "limite": 3000},
|
||
}
|
||
|
||
PERFILES = {
|
||
"ahorro": {"bpp": 0.055, "cq": 27, "nombre": "Ahorro (pesa menos)"},
|
||
"equilibrado": {"bpp": 0.085, "cq": 24, "nombre": "Equilibrado (recomendado)"},
|
||
"maxima": {"bpp": 0.14, "cq": 21, "nombre": "Máxima calidad"},
|
||
}
|
||
|
||
# Formatos de salida del convertidor. gpu/cpu = codificador de ffmpeg que lo hace; se usa la GPU si está
|
||
# disponible y si no la CPU. bpp_mult escala el bitrate objetivo del perfil (H.265 10 bits = 1.0).
|
||
# ProRes no va por bitrate: mbps_1080p30 es su peso aproximado a 1080p 30 fps para estimar.
|
||
FORMATOS = {
|
||
"h265_10bit": {"nombre": "MP4 H.265 10 bits · archivo (recomendado)", "ext": ".mp4", "codec": "hevc", "bits": 10, "sufijo": "_10bit",
|
||
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 1.0, "que": "unas 10 veces más ligero que el original, sin perder calidad visible; el que usa el respaldo"},
|
||
"h265_ligero": {"nombre": "MP4 H.265 8 bits · ligero para compartir", "ext": ".mp4", "codec": "hevc", "bits": 8, "sufijo": "_ligero",
|
||
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "cq_mas": 5,
|
||
"que": "para enviar por WhatsApp o Drive; pierde latitud de color"},
|
||
"h264": {"nombre": "MP4 H.264 8 bits · compatible con todo", "ext": ".mp4", "codec": "h264", "bits": 8, "sufijo": "_h264",
|
||
"gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "cq_mas": -2,
|
||
"que": "se abre en cualquier equipo o televisor; pesa más que H.265"},
|
||
"prores_proxy": {"nombre": "MOV ProRes Proxy · edición ligera", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_proxy",
|
||
"cpu": "prores_ks", "prores": 0, "mbps_1080p30": 45, "que": "para editar suave en Resolve o Premiere; luego se reenlaza al original"},
|
||
"prores_lt": {"nombre": "MOV ProRes 422 LT · edición", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_LT",
|
||
"cpu": "prores_ks", "prores": 1, "mbps_1080p30": 102, "que": "calidad de edición; pesa bastante"},
|
||
"prores_hq": {"nombre": "MOV ProRes 422 HQ · máster", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_HQ",
|
||
"cpu": "prores_ks", "prores": 3, "mbps_1080p30": 220, "que": "entrega o máster; pesa mucho"},
|
||
}
|
||
_MOTORES = {"ts": 0, "datos": None}
|
||
_BRAW = {"ts": 0, "datos": None}
|
||
|
||
|
||
def _lector_braw(*args, espera=120):
|
||
"""Lanza braw.py —proceso aparte: ahí se carga el decodificador de Blackmagic, no aquí— y devuelve lo que
|
||
dijo, una línea JSON por evento."""
|
||
carpeta = CONFIG["optimizar"].get("braw") or ""
|
||
r = subprocess.run([sys.executable, os.path.join(BASE, "braw.py"), *args], capture_output=True, text=True, encoding="utf-8",
|
||
errors="replace", timeout=espera, creationflags=SIN_VENTANA,
|
||
env=dict(os.environ, ORGANILAB_BRAW=carpeta) if carpeta else None)
|
||
out = []
|
||
for linea in (r.stdout or "").splitlines():
|
||
try:
|
||
out.append(json.loads(linea))
|
||
except ValueError:
|
||
pass
|
||
return out
|
||
|
||
|
||
def decodificador_braw():
|
||
"""¿Se pueden leer los BRAW sin Resolve? Lo dice braw.py después de cargar el decodificador de Blackmagic
|
||
y pedirle las interfaces que se usan. La respuesta se recuerda diez minutos."""
|
||
if _BRAW["datos"] and time.time() - _BRAW["ts"] < 600:
|
||
return _BRAW["datos"]
|
||
carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "")
|
||
datos = {"ok": False, "carpeta": "", "motivo": "no está el decodificador de Blackmagic RAW: se instala con DaVinci Resolve (vale el gratuito)"}
|
||
if carpeta:
|
||
try:
|
||
dicho = _lector_braw("sonda", carpeta, espera=60)
|
||
datos = dicho[-1] if dicho else {"ok": False, "carpeta": carpeta, "motivo": "el decodificador de Blackmagic RAW no respondió"}
|
||
except Exception as ex: # noqa
|
||
datos = {"ok": False, "carpeta": carpeta, "motivo": f"el decodificador de Blackmagic RAW no respondió: {ex}"}
|
||
_BRAW.update(ts=time.time(), datos=datos)
|
||
return datos
|
||
|
||
|
||
def medir_braw(ruta):
|
||
"""Lo que el decodificador dice de un BRAW: su tamaño de verdad (ffprobe lo lee con relleno y sin girar:
|
||
4128×2176 para un clip que es 2160×4096 en vertical), sus cuadros, su código de tiempo y su curva."""
|
||
try:
|
||
dicho = _lector_braw("info", ruta)
|
||
except Exception as ex: # noqa
|
||
return {"ok": False, "error": f"el lector de BRAW no respondió: {ex}"}
|
||
return dicho[-1] if dicho else {"ok": False, "error": "el lector de BRAW no dijo nada"}
|
||
|
||
|
||
def _curva(m):
|
||
return (m.get("gamma") or "BRAW").replace("Blackmagic Design ", "")
|
||
|
||
|
||
def motores():
|
||
"""Qué codificadores hay: GPU NVIDIA (NVENC) y CPU. Se prueba de verdad (un clip de 3 cuadros), no solo
|
||
por la lista de ffmpeg, porque la lista incluye nvenc aunque no haya tarjeta NVIDIA."""
|
||
if _MOTORES["datos"] and time.time() - _MOTORES["ts"] < 600:
|
||
return _MOTORES["datos"]
|
||
ff = ruta_herramienta("ffmpeg")
|
||
out = {"gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False},
|
||
"cpu": {"libx265": False, "libx264": False, "prores_ks": False},
|
||
"formatos": {}, "chroma422": False, "ffmpeg": bool(ff)}
|
||
disponibles = set()
|
||
if ff:
|
||
try:
|
||
r = subprocess.run([ff, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA)
|
||
for linea in r.stdout.splitlines():
|
||
partes = linea.split()
|
||
if len(partes) >= 2 and partes[0][:1] in ("V", "A") and len(partes[0]) == 6:
|
||
disponibles.add(partes[1])
|
||
except Exception: # noqa
|
||
pass
|
||
for k in ("libx265", "libx264", "prores_ks"):
|
||
out["cpu"][k] = k in disponibles
|
||
for k, enc in (("hevc", "hevc_nvenc"), ("h264", "h264_nvenc")):
|
||
if enc in disponibles:
|
||
try:
|
||
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=256x144:rate=30",
|
||
"-frames:v", "3", "-c:v", enc, "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
|
||
out["gpu"][k] = r.returncode == 0
|
||
except Exception: # noqa
|
||
pass
|
||
if out["gpu"]["hevc"]:
|
||
try:
|
||
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
|
||
"-i", "testsrc=size=256x144:rate=30", "-frames:v", "3",
|
||
"-c:v", "hevc_nvenc", "-pix_fmt", "p210le", "-profile:v", "rext",
|
||
"-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
|
||
out["gpu"]["chroma422"] = r.returncode == 0
|
||
except Exception: # noqa
|
||
out["gpu"]["chroma422"] = False
|
||
out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265"))
|
||
if out["gpu"]["hevc"]:
|
||
try: # ¿la tarjeta y su controlador admiten el ajuste «uhq»? (ver _args_video)
|
||
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
|
||
"-i", "testsrc=size=256x144:rate=30", "-frames:v", "3",
|
||
"-c:v", "hevc_nvenc", "-pix_fmt", "p010le", "-tune", "uhq", "-profile:v", "main10",
|
||
"-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
|
||
out["gpu"]["uhq"] = r.returncode == 0
|
||
except Exception: # noqa
|
||
out["gpu"]["uhq"] = False
|
||
try: # ¿descomprime la tarjeta (NVDEC)? ver _decodificar_con_tarjeta
|
||
r = subprocess.run([ff, "-hide_banner", "-hwaccels"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA)
|
||
out["gpu"]["nvdec"] = bool(out["gpu"]["hevc"] or out["gpu"]["h264"]) and "cuda" in (r.stdout or "").split()
|
||
except Exception: # noqa
|
||
out["gpu"]["nvdec"] = False
|
||
try:
|
||
r = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, creationflags=SIN_VENTANA)
|
||
out["gpu"]["nombre"] = (r.stdout or "").strip().splitlines()[0] if r.returncode == 0 and r.stdout.strip() else ""
|
||
except Exception: # noqa
|
||
pass
|
||
for k, f in FORMATOS.items():
|
||
m = None
|
||
if f.get("gpu") and out["gpu"].get(f["codec"]):
|
||
m = "gpu"
|
||
elif f.get("cpu") and out["cpu"].get(f["cpu"]):
|
||
m = "cpu"
|
||
out["formatos"][k] = m # None = no se puede con este ffmpeg
|
||
out["braw"] = decodificador_braw()
|
||
_MOTORES.update(ts=time.time(), datos=out)
|
||
return out
|
||
|
||
|
||
def cargar_json(ruta, defecto):
|
||
try:
|
||
with open(ruta, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except OSError:
|
||
return json.loads(json.dumps(defecto))
|
||
except ValueError as ex:
|
||
# el archivo existe pero no se puede leer (una edición a mano con una coma de más, un corte a
|
||
# medias). Antes se sustituía por los valores de fábrica y se guardaba encima: las carpetas
|
||
# registradas y los clientes desaparecían sin decir nada. Ahora se aparta con copia.
|
||
copia = f"{ruta}.roto-{time.strftime('%Y%m%d-%H%M%S')}"
|
||
try:
|
||
shutil.copy2(ruta, copia)
|
||
except OSError:
|
||
copia = "(no se pudo copiar)"
|
||
print(f"AVISO: {os.path.basename(ruta)} no se pudo leer ({ex}); se apartó en {copia} y se parte de cero.")
|
||
return json.loads(json.dumps(defecto))
|
||
|
||
|
||
def guardar_json(ruta, datos):
|
||
tmp = ruta + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(datos, f, ensure_ascii=False, indent=2)
|
||
os.replace(tmp, ruta)
|
||
|
||
|
||
CONFIG = cargar_json(CONFIG_PATH, CONFIG_DEFECTO)
|
||
for k, v in CONFIG_DEFECTO.items(): # completar claves nuevas sin pisar lo del usuario
|
||
CONFIG.setdefault(k, v)
|
||
if isinstance(v, dict):
|
||
for kk, vv in v.items():
|
||
CONFIG[k].setdefault(kk, vv)
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
|
||
|
||
def _sufijos_conversion():
|
||
"""El sufijo del convertidor se puede cambiar en Ajustes: las reglas tienen que conocerlo para saber
|
||
que «C0001_ligero2.mp4» sigue siendo el clip C0001 y no un exportado."""
|
||
propio = (CONFIG["optimizar"].get("sufijo") or "").strip().lower()
|
||
if len(propio) > 2 and propio not in R.SUFIJOS_CONVERSION:
|
||
R.SUFIJOS_CONVERSION = R.SUFIJOS_CONVERSION + (propio,)
|
||
|
||
|
||
_sufijos_conversion()
|
||
|
||
|
||
def ruta_herramienta(h):
|
||
"""ffmpeg/ffprobe: primero el empaquetado en ffmpeg\\ junto al programa, luego lo que diga la config, luego el PATH.
|
||
No se guarda en la config para que la carpeta se pueda mover de sitio."""
|
||
local = os.path.join(BASE, "ffmpeg", h + ".exe")
|
||
if os.path.exists(local):
|
||
return local
|
||
return CONFIG["optimizar"].get(h) or h
|
||
|
||
|
||
def herramientas():
|
||
"""qué hay disponible: ffmpeg/ffprobe, el decodificador de Blackmagic RAW y Resolve"""
|
||
out = {}
|
||
for h in ("ffmpeg", "ffprobe"):
|
||
ruta = ruta_herramienta(h)
|
||
ok = os.path.exists(ruta) or shutil.which(ruta) is not None
|
||
out[h] = {"ruta": ruta, "ok": ok, "empaquetado": ruta.lower().startswith(BASE.lower())}
|
||
out["resolve"] = {"ok": os.path.exists(r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll"), "ruta": r"C:\Program Files\Blackmagic Design\DaVinci Resolve"}
|
||
# solo se mira si el archivo está: cargarlo de verdad lo hace decodificador_braw(), en un proceso aparte
|
||
carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "")
|
||
out["braw"] = {"ok": bool(carpeta), "ruta": carpeta}
|
||
out["congelado"] = CONGELADO
|
||
return out
|
||
|
||
DICC = cargar_json(DICC_PATH, {"carpetas": R.DICCIONARIO_SEMILLA, "reglas": [], "aprendido": []})
|
||
DICC.setdefault("carpetas", dict(R.DICCIONARIO_SEMILLA))
|
||
DICC.setdefault("reglas", [])
|
||
DICC.setdefault("aprendido", [])
|
||
for k, v in R.DICCIONARIO_SEMILLA.items(): # palabras nuevas de la semilla, sin pisar lo aprendido
|
||
DICC["carpetas"].setdefault(k, v)
|
||
# Hasta la 3.1.1 «Por nombrar» preguntaba también por las carpetas de la propia app («basura», de
|
||
# _Para borrar\basura) y proponía «grabación» si dentro había clips. Lo que se aprendió así se olvida:
|
||
# nunca fue una carpeta del cliente.
|
||
for k in [k for k in DICC["carpetas"] if k in R.NOMBRES_DE_LA_APP and k not in R.DICCIONARIO_SEMILLA]:
|
||
del DICC["carpetas"][k]
|
||
guardar_json(DICC_PATH, DICC)
|
||
|
||
_act = CONFIG.get("actualizaciones")
|
||
if isinstance(_act, dict) and _act.get("auto") is True and not _act.get("elegido"):
|
||
_act["auto"] = False
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
|
||
PLANTILLAS = cargar_json(PLANTILLAS_PATH, R.PLANTILLAS)
|
||
for t, d in R.PLANTILLAS.items():
|
||
PLANTILLAS.setdefault(t, d)
|
||
for b, p in d.items():
|
||
PLANTILLAS[t].setdefault(b, p)
|
||
# la papelera se llamaba «_Papelera» y pasó a «_Para borrar», pero las plantillas guardadas ganan
|
||
# sobre las de fábrica, así que las instalaciones viejas seguían escribiendo en la carpeta vieja
|
||
# mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
|
||
if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
|
||
PLANTILLAS[t][b] = p
|
||
# los proyectos pasaron a separarse por programa: «01_Proyectos/{nombre}» -> «01_Proyectos/{app}/{nombre}»
|
||
if b == "PROYECTOS" and PLANTILLAS[t][b] == p.replace("{app}/", ""):
|
||
PLANTILLAS[t][b] = p
|
||
# y las grabaciones, por día de rodaje cuando el mes tiene más de uno
|
||
if b in ("GRABACION", "CELULAR") and PLANTILLAS[t][b] == p.replace("{dia}/", ""):
|
||
PLANTILLAS[t][b] = p
|
||
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
|
||
|
||
|
||
def raiz_cfg(ruta):
|
||
ruta = os.path.normpath(ruta)
|
||
for r in CONFIG["raices"]:
|
||
if os.path.normpath(r["ruta"]).lower() == ruta.lower():
|
||
return r
|
||
return None
|
||
|
||
|
||
def dentro_de(raiz, ruta):
|
||
"""True si `ruta` está dentro de `raiz` (o es la propia raíz). Evita mover cosas fuera de la carpeta del cliente."""
|
||
try:
|
||
a = os.path.normcase(os.path.normpath(raiz))
|
||
b = os.path.normcase(os.path.normpath(ruta))
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return b == a or b.startswith(a.rstrip(os.sep) + os.sep)
|
||
|
||
|
||
# ==========================================================================
|
||
# Base de datos
|
||
# ==========================================================================
|
||
|
||
def db():
|
||
con = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=60)
|
||
con.row_factory = sqlite3.Row
|
||
return con
|
||
|
||
|
||
def init_db():
|
||
con = db()
|
||
con.executescript("""
|
||
PRAGMA journal_mode=WAL;
|
||
CREATE TABLE IF NOT EXISTS archivos (
|
||
ruta TEXT PRIMARY KEY, raiz TEXT, dir TEXT, nombre TEXT, ext TEXT, bytes INTEGER, mtime REAL,
|
||
rol TEXT, fuente TEXT, contexto TEXT, anio_ruta INTEGER, mes_ruta INTEGER,
|
||
fecha TEXT, fecha_origen TEXT, es_basura INTEGER, es_proxy INTEGER,
|
||
codec TEXT, perfil TEXT, pix_fmt TEXT, ancho INTEGER, alto INTEGER, fps REAL, dur REAL,
|
||
kbps INTEGER, alfa INTEGER, acodec TEXT, analizado INTEGER DEFAULT 0
|
||
);
|
||
CREATE INDEX IF NOT EXISTS ix_arch_dir ON archivos(dir);
|
||
CREATE INDEX IF NOT EXISTS ix_arch_raiz ON archivos(raiz);
|
||
CREATE INDEX IF NOT EXISTS ix_arch_nb ON archivos(nombre, bytes);
|
||
CREATE TABLE IF NOT EXISTS carpetas (
|
||
ruta TEXT PRIMARY KEY, raiz TEXT, nombre TEXT, n INTEGER, bytes INTEGER,
|
||
anio_moda INTEGER, mes_moda INTEGER, frac_moda REAL, dias_rango INTEGER,
|
||
es_proxy INTEGER, es_basura INTEGER
|
||
);
|
||
CREATE INDEX IF NOT EXISTS ix_carp_raiz ON carpetas(raiz);
|
||
CREATE TABLE IF NOT EXISTS escaneos (raiz TEXT PRIMARY KEY, ts REAL, n INTEGER, bytes INTEGER, seg REAL);
|
||
CREATE TABLE IF NOT EXISTS trabajos (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, ruta TEXT, salida TEXT, motor TEXT, perfil TEXT,
|
||
chroma TEXT, color TEXT, kbps INTEGER, estado TEXT, pct REAL DEFAULT 0, log TEXT DEFAULT '',
|
||
bytes_in INTEGER, bytes_out INTEGER, creado REAL, fin REAL, raiz TEXT
|
||
);
|
||
""")
|
||
con.commit()
|
||
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",
|
||
"ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'",
|
||
"ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1",
|
||
# con qué versión de las reglas se hizo el inventario (R.VERSION_REGLAS)
|
||
"ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0",
|
||
# «primero este»: la cola va por orden descendente y luego por id
|
||
"ALTER TABLE trabajos ADD COLUMN orden INTEGER DEFAULT 0",
|
||
# la nota de calidad (VMAF) medida en «comparar»: media y cuadro peor
|
||
"ALTER TABLE trabajos ADD COLUMN nota REAL", "ALTER TABLE trabajos ADD COLUMN nota_min REAL"):
|
||
try:
|
||
con.execute(alter)
|
||
con.commit()
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
con.close()
|
||
|
||
|
||
init_db()
|
||
|
||
|
||
def con_sep(ruta):
|
||
r"""la ruta con un separador al final, sin duplicarlo (N: ya lo trae)"""
|
||
ruta = os.path.normpath(ruta)
|
||
return ruta if ruta.endswith(os.sep) else ruta + os.sep
|
||
|
||
|
||
def esc_like(s):
|
||
return s.replace("!", "!!").replace("%", "!%").replace("_", "!_")
|
||
|
||
|
||
def archivos_bajo(con, carpeta):
|
||
"""filas de archivos dentro de una carpeta (recursivo)"""
|
||
c = os.path.normpath(carpeta)
|
||
return con.execute(
|
||
"SELECT * FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'",
|
||
(c, esc_like(con_sep(c)) + "%")).fetchall()
|
||
|
||
|
||
def archivos_en(con, carpeta):
|
||
return con.execute("SELECT * FROM archivos WHERE dir = ?", (os.path.normpath(carpeta),)).fetchall()
|
||
|
||
|
||
# ==========================================================================
|
||
# Escaneo (inventario)
|
||
# ==========================================================================
|
||
|
||
PROGRESO = {} # raiz -> dict
|
||
|
||
|
||
def contexto_dir(nombre, ctx_padre, nivel_padre):
|
||
"""cubeta de contexto que hereda un archivo por la carpeta donde está"""
|
||
if R.detectar_anio(nombre):
|
||
return "ANIO", "anio"
|
||
if nivel_padre == "anio" and R.detectar_mes(nombre)[0]:
|
||
return "MES", "mes"
|
||
if nivel_padre == "mes" and R.detectar_pieza(nombre):
|
||
return "PIEZA", "pieza"
|
||
cub, _ = R.cubeta_carpeta(nombre, DICC["carpetas"])
|
||
if cub in (None, "CONTENEDOR"):
|
||
return ctx_padre, nivel_padre if cub == "CONTENEDOR" else nivel_padre
|
||
if cub == "PIEZA":
|
||
return "PIEZA", "pieza"
|
||
return cub, nivel_padre
|
||
|
||
|
||
def _es_enlace(entrada):
|
||
"""True si la entrada es un enlace o una unión de Windows (mklink /D o /J). `is_symlink` no basta:
|
||
para una unión devuelve False, y aun así al entrar se recorre otro árbol."""
|
||
try:
|
||
if entrada.is_symlink():
|
||
return True
|
||
return bool(entrada.stat(follow_symlinks=False).st_file_attributes & stat_mod.FILE_ATTRIBUTE_REPARSE_POINT)
|
||
except (OSError, AttributeError):
|
||
return False
|
||
|
||
|
||
def _id_carpeta(ruta):
|
||
"""Identificador real de una carpeta en el disco, para no recorrer dos veces la misma."""
|
||
st = os.stat(ruta)
|
||
return (st.st_dev, st.st_ino) if st.st_ino else os.path.normcase(os.path.abspath(ruta))
|
||
|
||
|
||
def escanear(ruta):
|
||
raiz = os.path.normpath(ruta)
|
||
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
|
||
filas = []
|
||
fallos = [] # carpetas o archivos que no se pudieron leer: deciden si el escaneo vale
|
||
dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
|
||
props = ("n", "bytes")
|
||
|
||
def agrega_dir(d, size):
|
||
info = dirs.get(d)
|
||
if info is None:
|
||
info = dirs[d] = [0, 0, Counter(), 0, 0, os.path.basename(d)]
|
||
info[0] += 1
|
||
info[1] += size
|
||
|
||
def subir(d):
|
||
"""`d` y todas las carpetas de encima, hasta la raíz."""
|
||
while True:
|
||
yield d
|
||
if os.path.normpath(d).lower() == raiz.lower():
|
||
return
|
||
padre = os.path.dirname(d)
|
||
if padre == d:
|
||
return
|
||
d = padre
|
||
|
||
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None):
|
||
# `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows
|
||
# (mklink /J) parece una carpeta normal, y una que apunte hacia arriba dejaba el escaneo dando
|
||
# vueltas sin fin (comprobado: un árbol con un archivo no terminaba en dos minutos).
|
||
if vistos is None:
|
||
vistos = set()
|
||
try:
|
||
clave = _id_carpeta(path)
|
||
if clave in vistos:
|
||
fallos.append(f"{path}: es un acceso directo a una carpeta ya recorrida; no se entra")
|
||
return
|
||
vistos = vistos | {clave}
|
||
except OSError:
|
||
pass
|
||
try:
|
||
it = os.scandir(path)
|
||
except OSError as ex:
|
||
fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})")
|
||
return
|
||
prog["actual"] = path
|
||
with it:
|
||
for e in it:
|
||
try:
|
||
nombre = e.name
|
||
if e.is_dir(follow_symlinks=False):
|
||
if nombre.lower() in ("#recycle", "@eadir", R.CARPETA_APP):
|
||
continue # lo de la app (sus iconos de color) no es material del cliente
|
||
# dentro de la M4ROOT de una tarjeta Sony, SUB son los proxies que graba la cámara y
|
||
# THMBNL y GENERAL sus miniaturas e índices: no son material
|
||
sony = os.path.basename(path).upper() == "M4ROOT"
|
||
p = en_proxy or R.es_proxy_dir(nombre) or (sony and nombre.upper() == "SUB")
|
||
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM \
|
||
or (sony and nombre.upper() in ("THMBNL", "GENERAL"))
|
||
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
|
||
# medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de
|
||
# material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye
|
||
# por defecto. Solo hereda la marca una carpeta con nombre completo de paquete de
|
||
# Envato; lo que de verdad se descargó se reconoce por el nombre del propio archivo.
|
||
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip()))
|
||
c2, n2 = contexto_dir(nombre, ctx, nivel)
|
||
a2, m2 = anio, mes
|
||
if c2 == "ANIO":
|
||
a2, m2 = R.detectar_anio(nombre), None
|
||
elif c2 == "MES":
|
||
m2 = R.detectar_mes(nombre)[0]
|
||
dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre])
|
||
dirs[e.path][3] = 1 if p else 0
|
||
dirs[e.path][4] = 1 if b else 0
|
||
if _es_enlace(e):
|
||
fallos.append(f"{e.path}: es un acceso directo a otra carpeta; no se entra")
|
||
continue
|
||
walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
|
||
else:
|
||
if nombre.lower() == "desktop.ini" and _ini_nuestro(e.path):
|
||
continue # el color de la carpeta lo puso la app: ni se indexa ni se aparta
|
||
st = e.stat(follow_symlinks=False)
|
||
ext = os.path.splitext(nombre)[1].lower()
|
||
firma = R.firma_archivo(nombre)
|
||
rol = R.rol_archivo(nombre, ext, firma)
|
||
basura = 1 if (en_basura or rol == "basura") else 0
|
||
proxy = 1 if (en_proxy or rol == "proxy") else 0
|
||
stock = 1 if (en_stock or rol == "stock") else 0
|
||
fecha, origen = R.fecha_de_nombre(nombre, R.fecha_real(st))
|
||
filas.append((e.path, raiz, path, nombre, ext, st.st_size, st.st_mtime, rol, firma or "",
|
||
ctx or "", anio, mes, fecha, origen, basura, proxy, stock))
|
||
prog["n"] += 1
|
||
prog["bytes"] += st.st_size
|
||
for d in subir(path):
|
||
agrega_dir(d, st.st_size)
|
||
except OSError as ex:
|
||
fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})")
|
||
continue
|
||
|
||
try:
|
||
# Si mientras se recorre la carpeta la app mueve algo de ella («Aplicar», apartar, la cola), lo recogido
|
||
# mezcla el antes y el después: un archivo visto en su sitio y luego otra vez en la papelera, o en
|
||
# ninguno. Medido el 14-oct-2026 en Vía Sumapaz: un proxy apartado a las 10:02, en pleno escaneo, quedó
|
||
# dos veces en el inventario. Entonces se vuelve a recorrer (hasta tres veces).
|
||
for vuelta in range(3):
|
||
movimientos_antes = _TOCADO.get(raiz.lower(), 0)
|
||
walk(raiz, False, False, False, "", "raiz", None, None)
|
||
prog["fallos"] = len(fallos)
|
||
_fechas_de_rodaje(filas)
|
||
_heredar_fechas(filas)
|
||
# de qué mes es cada carpeta: se cuenta al final, con las fechas ya corregidas, y sin la basura
|
||
# ni los proxies (un .DS_Store de otro año no dice nada del material)
|
||
for f in filas:
|
||
if f[14] or f[15]:
|
||
continue
|
||
am = (int(f[12][:4]), int(f[12][5:7]))
|
||
for d in subir(f[2]):
|
||
dirs[d][2][am] += 1
|
||
revuelto = _TOCADO.get(raiz.lower(), 0) != movimientos_antes
|
||
if not revuelto or vuelta == 2:
|
||
break
|
||
filas.clear()
|
||
fallos.clear()
|
||
dirs.clear()
|
||
prog.update(n=0, bytes=0, aviso="se movieron archivos mientras se escaneaba: se vuelve a recorrer")
|
||
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()}
|
||
# y la de los que solo cambiaron de carpeta (movidos con el Explorador): mismo nombre, peso y fecha
|
||
por_firma = {(os.path.basename(r["ruta"]).lower(), r["bytes"]): r for r in previo.values()}
|
||
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]) or por_firma.get((f[3].lower(), f[5]))
|
||
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]))
|
||
_aplicar_relojes(con, raiz) # las fechas corregidas a mano siguen corregidas
|
||
cfilas = []
|
||
for d, info in dirs.items():
|
||
n, b, fechas, p, bs, nombre = info
|
||
if fechas:
|
||
(ay, am), cnt = fechas.most_common(1)[0]
|
||
frac = cnt / sum(fechas.values())
|
||
claves = sorted(fechas)
|
||
d0 = datetime.date(claves[0][0], claves[0][1], 1)
|
||
d1 = datetime.date(claves[-1][0], claves[-1][1], 1)
|
||
rango = (d1 - d0).days
|
||
else:
|
||
ay, am, frac, rango = None, None, 0, 0
|
||
cfilas.append((d, raiz, nombre, n, b, ay, am, frac, rango, p, bs))
|
||
con.executemany("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", cfilas)
|
||
con.execute("INSERT OR REPLACE INTO escaneos (raiz, ts, n, bytes, seg, reglas) VALUES (?,?,?,?,?,?)",
|
||
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"], R.VERSION_REGLAS))
|
||
con.close()
|
||
prog["estado"] = "listo"
|
||
if fallos:
|
||
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
|
||
elif revuelto:
|
||
prog["error"] = "se siguieron moviendo archivos mientras se escaneaba: vuelve a escanear cuando acabe"
|
||
try:
|
||
reubicar_trabajos(raiz) # la cola encuentra los clips que se movieron desde el último escaneo
|
||
except Exception: # noqa
|
||
pass
|
||
olvidar_plan(raiz, recalcular=False) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
|
||
except Exception as ex: # noqa
|
||
prog["estado"] = "error"
|
||
prog["error"] = repr(ex)
|
||
prog["fin"] = time.time()
|
||
|
||
|
||
def _heredar_fechas(filas):
|
||
"""Un render o una conversión se crean mucho después del rodaje, así que su fecha no dice nada del
|
||
material: si el original está en la misma carpeta, el derivado toma SU fecha.
|
||
|
||
Así «C0001_10bit.mp4» se archiva en el mes en que se grabó «C0001.MP4», no en el mes en que se
|
||
convirtió, y el mes de una carpeta se decide por el material de verdad."""
|
||
I_RUTA, I_DIR, I_NOMBRE, I_FECHA, I_ORIGEN = 0, 2, 3, 12, 13
|
||
por_dir = defaultdict(dict)
|
||
en_todo = defaultdict(list)
|
||
for f in filas:
|
||
tallo = os.path.splitext(f[I_NOMBRE])[0].lower()
|
||
por_dir[f[I_DIR].lower()][tallo] = f
|
||
en_todo[tallo].append(f)
|
||
heredadas = 0
|
||
for i, f in enumerate(filas):
|
||
orig = None
|
||
# primero el original del todo («C0001» para «C0001_10bit_10bit.mp4») y, si no está, el de un
|
||
# paso atrás: una conversión de una conversión no debe quedarse con la fecha de la primera
|
||
for base in dict.fromkeys(b for b in (R.raiz_derivado(f[I_NOMBRE]), R.base_derivado(f[I_NOMBRE])) if b):
|
||
orig = por_dir[f[I_DIR].lower()].get(base.lower())
|
||
if orig is None and len(base) >= 6:
|
||
# el render puede estar en otra carpeta («_Exportados», «_Archivo»): vale si el nombre es
|
||
# bastante propio y no hay dos candidatos que se llamen igual en toda la carpeta del cliente
|
||
cands = en_todo.get(base.lower()) or []
|
||
orig = cands[0] if len(cands) == 1 else None
|
||
if orig is not None:
|
||
break
|
||
if orig is None or orig is f or orig[I_ORIGEN] == "derivado":
|
||
continue
|
||
if orig[I_FECHA] >= f[I_FECHA]:
|
||
continue # el «original» no es más viejo: no hereda nada
|
||
nueva = list(f)
|
||
nueva[I_FECHA] = orig[I_FECHA]
|
||
nueva[I_ORIGEN] = "derivado" # la fecha viene del archivo del que salió
|
||
filas[i] = tuple(nueva)
|
||
heredadas += 1
|
||
return heredadas
|
||
|
||
|
||
def _fechas_de_rodaje(filas):
|
||
"""Los clips de Blackmagic se llaman por la tarjeta, el mes y el día («A014_1209…»), sin año. El año
|
||
sale de la fecha del archivo, y esa cambia cuando una conversión o una copia lo reescriben.
|
||
|
||
Todos los clips de la misma tarjeta y el mismo día son del mismo rodaje, así que comparten año: si
|
||
uno conserva su fecha de cámara, ese año es seguro para todos; si ninguno, vale el más antiguo, porque
|
||
un clip no puede ser posterior a ninguna de sus copias. Medido el 4-oct-2026 sobre el material real:
|
||
un mismo rodaje del 2 de marzo estaba partido entre 2025 y 2026 solo porque la mitad de los clips se
|
||
había vuelto a guardar en julio."""
|
||
I_NOMBRE, I_FECHA, I_ORIGEN = 3, 12, 13
|
||
grupos = defaultdict(list)
|
||
for i, f in enumerate(filas):
|
||
if f[I_ORIGEN] in ("nombre", "nombre+mtime"):
|
||
clave = R.clave_rodaje(f[I_NOMBRE])
|
||
if clave:
|
||
grupos[clave].append(i)
|
||
cambiadas = 0
|
||
for idx in grupos.values():
|
||
seguras = {filas[i][I_FECHA] for i in idx if filas[i][I_ORIGEN] == "nombre"}
|
||
if len(seguras) > 1:
|
||
continue # la misma tarjeta y día en dos años distintos: no se decide
|
||
fecha = next(iter(seguras)) if seguras else min(filas[i][I_FECHA] for i in idx)
|
||
origen = "nombre" if seguras else "nombre+mtime"
|
||
for i in idx:
|
||
f = filas[i]
|
||
if f[I_FECHA] == fecha and f[I_ORIGEN] == origen:
|
||
continue
|
||
nueva = list(f)
|
||
nueva[I_FECHA], nueva[I_ORIGEN] = fecha, origen
|
||
filas[i] = tuple(nueva)
|
||
cambiadas += 1
|
||
# Un rodaje que pasa de medianoche es un solo rodaje. El nombre trae también la hora («A006_05312107»,
|
||
# «A006_06010000»): lo grabado de madrugada cuenta como del día en que se empezó, si ese día se
|
||
# estaba grabando por la noche. Medido el 4-oct-2026: un rodaje del 31 de mayo a las 21:07 al 1 de
|
||
# junio a las 04:00 salía partido en dos meses, 63 clips en mayo y 103 en junio.
|
||
de_noche = set()
|
||
for idx in grupos.values():
|
||
for i in idx:
|
||
try:
|
||
if int(filas[i][I_NOMBRE][9:11]) >= 18:
|
||
de_noche.add(filas[i][I_FECHA])
|
||
except ValueError:
|
||
pass
|
||
for idx in grupos.values():
|
||
for i in idx:
|
||
f = filas[i]
|
||
try:
|
||
if int(f[I_NOMBRE][9:11]) >= 6:
|
||
continue
|
||
vispera = (datetime.date.fromisoformat(f[I_FECHA]) - datetime.timedelta(days=1)).isoformat()
|
||
except ValueError:
|
||
continue
|
||
if vispera in de_noche:
|
||
nueva = list(f)
|
||
nueva[I_FECHA] = vispera
|
||
filas[i] = tuple(nueva)
|
||
cambiadas += 1
|
||
return cambiadas
|
||
|
||
|
||
def lanzar_escaneo(ruta):
|
||
raiz = os.path.normpath(ruta)
|
||
p = PROGRESO.get(raiz)
|
||
if p and p["estado"] == "corriendo":
|
||
return False
|
||
threading.Thread(target=_escanear_y_leer, args=(raiz,), daemon=True).start()
|
||
return True
|
||
|
||
|
||
def _escanear_y_leer(raiz):
|
||
"""El escaneo y, si está el escaneo profundo (por defecto sí), después la lectura de cada clip: códec,
|
||
duración y resolución, en segundo plano y solo de lo que no se había leído. El escaneo solo mira nombres,
|
||
tamaños y fechas; sin leer los clips no se sabe si dos que se llaman igual son el mismo con otra calidad
|
||
(2.387 «por comparar» en Vía Sumapaz) ni cuáles conviene convertir."""
|
||
escanear(raiz)
|
||
ajuste = CONFIG.get("escaneo") or {}
|
||
if (PROGRESO.get(raiz) or {}).get("estado") != "listo" or not ajuste.get("profundo", True):
|
||
return
|
||
a = ANALISIS.get(raiz)
|
||
if a and a.get("estado") == "corriendo":
|
||
return
|
||
analizar(raiz, int(ajuste.get("min_mb", 5)))
|
||
|
||
|
||
# ---- el reloj de una cámara
|
||
# Una cámara con el reloj sin poner fecha sus clips en 2008 o 2009 (medido en Vía Sumapaz: 250 clips de la
|
||
# Blackmagic «del 28 de julio de 2008»). La app lo detecta; aquí se corrige: se dice la fecha real del primer
|
||
# clip de esa tanda y los demás se corren lo mismo. La corrección se guarda por archivo (nombre y peso), así
|
||
# sobrevive a volver a escanear y a que el plan mueva los clips de carpeta.
|
||
_ROLES_RELOJ = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara")
|
||
_ANIO_RELOJ = "2014"
|
||
|
||
|
||
def _tabla_relojes(con):
|
||
con.execute("CREATE TABLE IF NOT EXISTS relojes (raiz TEXT, nombre TEXT, bytes INTEGER, dias INTEGER, grupo TEXT, "
|
||
"dir TEXT, fuente TEXT, ts REAL)")
|
||
|
||
|
||
def _aplicar_relojes(con, raiz):
|
||
"""Vuelve a aplicar las correcciones de reloj de esta carpeta (después de escanear, la fecha vuelve a ser la
|
||
de la cámara)."""
|
||
_tabla_relojes(con)
|
||
con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), (SELECT r.dias || ' days' FROM relojes r "
|
||
"WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes LIMIT 1)), "
|
||
"fecha_origen = 'corregida' WHERE raiz = ? AND fecha_origen != 'corregida' AND EXISTS (SELECT 1 FROM "
|
||
"relojes r WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes)", (raiz,))
|
||
|
||
|
||
def _nombres_fuente():
|
||
out = {}
|
||
for k, _, d in R.FIRMAS:
|
||
out.setdefault(k, d)
|
||
return out
|
||
|
||
|
||
def relojes(raiz):
|
||
"""Las tandas de clips con fecha imposible, con una pista de su fecha real; y las ya corregidas."""
|
||
raiz = _clave(raiz)
|
||
con = db()
|
||
_tabla_relojes(con)
|
||
roles = ",".join("?" * len(_ROLES_RELOJ))
|
||
grupos = [dict(x) for x in con.execute(
|
||
f"SELECT dir, fuente, COUNT(*) n, SUM(bytes) bytes, MIN(substr(fecha, 1, 10)) desde, MAX(substr(fecha, 1, 10)) hasta, "
|
||
f"MIN(nombre) ejemplo FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({roles}) "
|
||
f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ? GROUP BY dir, fuente "
|
||
f"ORDER BY SUM(bytes) DESC", (raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()]
|
||
nf = _nombres_fuente()
|
||
for g in grupos:
|
||
g["rel"] = os.path.relpath(g["dir"], raiz)
|
||
g["camara"] = nf.get(g["fuente"], g["fuente"] or "")
|
||
# la pista: la fecha de otros clips de esa misma carpeta que sí la traen bien, o el año que diga la ruta
|
||
bien = [x[0] for x in con.execute(
|
||
f"SELECT substr(fecha, 1, 10) FROM archivos WHERE raiz = ? AND dir = ? AND es_basura = 0 AND rol IN ({roles}, 'celular_video') "
|
||
f"AND (fecha_origen IN ('nombre', 'corregida') OR substr(fecha, 1, 4) >= ?) ORDER BY fecha",
|
||
(raiz, g["dir"]) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()]
|
||
sug, por_que = "", ""
|
||
if bien:
|
||
sug = bien[len(bien) // 2]
|
||
por_que = (f"es la fecha de otro clip de esa misma carpeta que sí la trae bien" if len(bien) == 1 else
|
||
f"es la fecha de otros {len(bien):,} clips de esa misma carpeta que sí la traen bien".replace(",", "."))
|
||
else:
|
||
anios = [a for a in (R.detectar_anio(s) for s in g["rel"].split(os.sep)) if a]
|
||
if anios:
|
||
sug = f"{anios[-1]}-{g['desde'][5:]}"
|
||
por_que = f"la carpeta dice {anios[-1]}; el mes y el día son los de la cámara"
|
||
g.update(sugerencia=sug, por_que=por_que)
|
||
hechos = [dict(x) for x in con.execute(
|
||
"SELECT grupo, dir, fuente, COUNT(*) n, MAX(dias) dias, MAX(ts) ts FROM relojes WHERE raiz = ? GROUP BY grupo ORDER BY ts DESC",
|
||
(raiz,)).fetchall()]
|
||
for h in hechos:
|
||
h["rel"] = os.path.relpath(h["dir"], raiz) if h["dir"] else ""
|
||
h["camara"] = nf.get(h["fuente"], h["fuente"] or "")
|
||
con.close()
|
||
return {"grupos": grupos, "corregidos": hechos}
|
||
|
||
|
||
def corregir_reloj(raiz, dir_, fuente, fecha_real):
|
||
raiz = _clave(raiz)
|
||
try:
|
||
real = datetime.date.fromisoformat((fecha_real or "")[:10])
|
||
except ValueError:
|
||
return {"error": "escribe la fecha real del primer clip"}
|
||
if real > datetime.date.today():
|
||
return {"error": "esa fecha todavía no ha llegado"}
|
||
con = db()
|
||
_tabla_relojes(con)
|
||
cond = (f"raiz = ? AND dir = ? AND fuente = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) "
|
||
f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?")
|
||
args = (raiz, os.path.normpath(dir_ or ""), fuente or "") + _ROLES_RELOJ + (_ANIO_RELOJ,)
|
||
filas = con.execute(f"SELECT nombre, bytes, substr(fecha, 1, 10) f FROM archivos WHERE {cond}", args).fetchall()
|
||
if not filas:
|
||
con.close()
|
||
return {"error": "ahí ya no queda nada que corregir"}
|
||
desde = min(x["f"] for x in filas)
|
||
dias = (real - datetime.date.fromisoformat(desde)).days
|
||
grupo = f"{time.time():.3f}"
|
||
with con:
|
||
con.executemany("INSERT INTO relojes VALUES (?,?,?,?,?,?,?,?)",
|
||
[(raiz, x["nombre"], x["bytes"], dias, grupo, os.path.normpath(dir_), fuente or "", time.time()) for x in filas])
|
||
_aplicar_relojes(con, raiz)
|
||
con.close()
|
||
bitacora({"op": "reloj", "raiz": raiz, "dir": dir_, "fuente": fuente, "dias": dias, "n": len(filas)})
|
||
olvidar_plan(raiz)
|
||
return {"ok": True, "n": len(filas), "dias": dias, "desde": desde, "a": real.isoformat()}
|
||
|
||
|
||
def deshacer_reloj(raiz, grupo):
|
||
raiz = _clave(raiz)
|
||
con = db()
|
||
_tabla_relojes(con)
|
||
filas = con.execute("SELECT nombre, bytes, dias FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo)).fetchall()
|
||
if not filas:
|
||
con.close()
|
||
return {"error": "esa corrección ya no está"}
|
||
with con:
|
||
for x in filas:
|
||
con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), ?), fecha_origen = 'mtime' WHERE raiz = ? AND "
|
||
"nombre = ? AND bytes = ? AND fecha_origen = 'corregida'", (f"{-x['dias']} days", raiz, x["nombre"], x["bytes"]))
|
||
con.execute("DELETE FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo))
|
||
con.close()
|
||
olvidar_plan(raiz)
|
||
return {"ok": True, "n": len(filas)}
|
||
|
||
|
||
# ---- buscar en todos los clientes
|
||
_SIN_TILDES = str.maketrans("áéíóúüñàèìòùÁÉÍÓÚÜÑÀÈÌÒÙ", "aeiouunaeiouAEIOUUNAEIOU")
|
||
|
||
|
||
def _llano(t):
|
||
return (t or "").translate(_SIN_TILDES).lower()
|
||
|
||
|
||
def buscar(texto, limite=200):
|
||
"""Un archivo o una carpeta por su nombre, en todas las carpetas de clientes escaneadas. Antes solo había
|
||
«¿Dónde va a parar este archivo?», que dice dónde iría, no dónde está."""
|
||
pals = [p for p in _llano(texto).replace("\\", " ").replace("/", " ").split() if p]
|
||
if not pals or max(len(p) for p in pals) < 2:
|
||
return {"error": "escribe al menos dos letras"}
|
||
clientes = {_clave(r["ruta"]).lower(): r["cliente"] for r in CONFIG["raices"]}
|
||
con = db()
|
||
# un primer filtro en la base (sin tildes no se puede: SQLite solo pasa a minúsculas lo que no lleva tilde)
|
||
ancla = max(pals, key=len)
|
||
filas = con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos "
|
||
"WHERE lower(ruta) LIKE ? ESCAPE '!' OR ruta LIKE ? ESCAPE '!'",
|
||
("%" + esc_like(ancla) + "%", "%" + esc_like(ancla.upper()) + "%")).fetchall() \
|
||
if ancla.isascii() else con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos").fetchall()
|
||
con.close()
|
||
hay = []
|
||
for f in filas:
|
||
r = _llano(f["ruta"])
|
||
if all(p in r for p in pals):
|
||
en_nombre = all(p in _llano(f["nombre"]) for p in pals)
|
||
hay.append((0 if en_nombre else 1, -(f["bytes"] or 0), f))
|
||
hay.sort(key=lambda x: (x[0], x[1]))
|
||
out = []
|
||
for _, _, f in hay[:limite]:
|
||
out.append({"ruta": f["ruta"], "dir": f["dir"], "nombre": f["nombre"], "bytes": f["bytes"], "fecha": (f["fecha"] or "")[:10],
|
||
"rol": R.ROL_QUE.get(f["rol"], f["rol"]), "raiz": f["raiz"], "cliente": clientes.get((f["raiz"] or "").lower(), f["raiz"]),
|
||
"rel": os.path.relpath(f["dir"], f["raiz"]) if f["raiz"] else f["dir"],
|
||
"apartado": bool(f["es_basura"] or f["es_proxy"])})
|
||
return {"total": len(hay), "resultados": out, "carpetas": len(clientes)}
|
||
|
||
|
||
# ---- el mismo cliente en otros discos
|
||
_DISCOS_REFRESCO = {"activo": False}
|
||
|
||
|
||
def _refrescar_discos():
|
||
try:
|
||
discos()
|
||
finally:
|
||
_DISCOS_REFRESCO["activo"] = False
|
||
|
||
|
||
def otros_sitios(raiz):
|
||
"""Dónde más está el cliente de esta carpeta, según los discos conectados. No espera: usa lo último que se
|
||
miró y, si es viejo, lo vuelve a mirar en segundo plano (mirar todos los discos de red tarda)."""
|
||
r = raiz_cfg(raiz)
|
||
if not r:
|
||
return []
|
||
if time.time() - (_DISCOS_CACHE.get("ts") or 0) > 120 and not _DISCOS_REFRESCO["activo"]:
|
||
_DISCOS_REFRESCO["activo"] = True
|
||
threading.Thread(target=_refrescar_discos, daemon=True).start()
|
||
d = _DISCOS_CACHE.get("datos")
|
||
if not d:
|
||
return []
|
||
registradas = [_clave(x["ruta"]).lower() for x in CONFIG["raices"]]
|
||
out = []
|
||
for ruta in d["clientes"].get(r["cliente"], []):
|
||
rl = os.path.normpath(ruta).lower()
|
||
if any(rl == x or rl.startswith(x + os.sep) or x.startswith(rl + os.sep) for x in registradas):
|
||
continue
|
||
disco = next((x for x in d["discos"] if x["letra"].lower() == ruta[:2].lower()), {})
|
||
out.append({"ruta": ruta, "unc": disco.get("unc", ""), "disco": disco.get("nombre", "")})
|
||
return out
|
||
|
||
|
||
# ==========================================================================
|
||
# Utilidades de fechas / nombres para el plan
|
||
# ==========================================================================
|
||
|
||
def fecha_carpeta(con, ruta):
|
||
"""(anio, mes, confianza, motivo) para una carpeta según sus archivos"""
|
||
fila = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (os.path.normpath(ruta),)).fetchone()
|
||
if not fila or not fila["anio_moda"]:
|
||
return None, None, "baja", "sin archivos con fecha"
|
||
frac = fila["frac_moda"] or 0
|
||
if frac >= 0.8:
|
||
return fila["anio_moda"], fila["mes_moda"], "media", f"fecha de sus archivos ({int(frac * 100)}% del mismo mes)"
|
||
return fila["anio_moda"], fila["mes_moda"], "baja", f"fechas mezcladas ({fila['dias_rango']} días de rango)"
|
||
|
||
|
||
def limpiar_nombre(nombre):
|
||
n = re.sub(r"\s{2,}", " ", nombre).strip(" -_.")
|
||
return n or nombre
|
||
|
||
|
||
def destino_plantilla(tipo, cubeta, **kw):
|
||
plantilla = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get(cubeta)
|
||
if not plantilla:
|
||
plantilla = PLANTILLAS["contenido"].get(cubeta, "_Revisar/{nombre}")
|
||
anio = kw.get("anio")
|
||
mes = kw.get("mes")
|
||
partes = {
|
||
"anio": str(anio) if anio else "",
|
||
"mes": R.nombre_mes(mes) if mes else "",
|
||
"nombre": kw.get("nombre", ""),
|
||
"dia": kw.get("dia", ""),
|
||
"sub": kw.get("sub", ""),
|
||
"rel": kw.get("rel", ""),
|
||
"fuente": kw.get("fuente", ""),
|
||
"app": kw.get("app", ""),
|
||
}
|
||
# un valor vacío se lleva consigo el separador que lo precedía: "{anio}-{mes}_Grabacion" sin mes -> "2026_Grabacion"
|
||
vacio = "\x00"
|
||
partes_o = {k: (v if v != "" else vacio) for k, v in partes.items()}
|
||
segmentos = []
|
||
for seg in plantilla.split("/"):
|
||
s = seg.format(**partes_o)
|
||
hueco = vacio in s
|
||
s = re.sub(r"[-_ ]*\x00", "", s)
|
||
# los separadores sueltos solo se recortan si los dejó una variable vacía: sin esto, una ruta que
|
||
# de verdad empieza por guion bajo («_Entrada\Tarjeta») llegaba a la papelera como «Entrada\Tarjeta»
|
||
if hueco and seg.startswith("{"):
|
||
s = s.lstrip("-_ ")
|
||
if hueco and seg.endswith("}"):
|
||
s = s.rstrip("-_ ")
|
||
if s:
|
||
segmentos.append(s)
|
||
if not segmentos:
|
||
segmentos = ["_Sin fecha"]
|
||
return os.sep.join(segmentos)
|
||
|
||
|
||
# ==========================================================================
|
||
# Qué es una carpeta por lo que tiene dentro
|
||
# ==========================================================================
|
||
|
||
def _mes_claro(nombre):
|
||
"""True si la carpeta se llama como un mes sin lugar a dudas («Mayo», «11-Noviembre», «Marzo 2026»).
|
||
«Mar», «May» o «05» pueden ser otra cosa y no cuentan."""
|
||
m, dudoso = R.detectar_mes(re.sub(r"20\d\d", " ", nombre or "").strip(" -_"))
|
||
return bool(m) and not dudoso
|
||
|
||
|
||
class Estructura:
|
||
"""El árbol de una carpeta de cliente tal como quedó en el inventario, con lo que cuelga de cada rama.
|
||
|
||
El nombre de una carpeta no basta para saber qué es. Medido el 4-oct-2026 sobre el material real:
|
||
«Orden» (533 archivos, 65 GB) se aprendió como grabación porque casi todo su peso eran clips, y el plan
|
||
la iba a desarmar entera por cámara y por mes; dentro había una organización anterior completa, con
|
||
sus grabaciones, sus proyectos por mes y sus portadas. Aquí se mira la forma: una tarjeta volcada es
|
||
casi todo clips de cámara; una pieza trae su carpeta de exportados, de portada y de música; y una
|
||
carpeta que guarda meses o años dentro es un cajón que hay que abrir, no una cosa que mover."""
|
||
|
||
def __init__(self, con, raiz, dicc):
|
||
self.raiz = os.path.normpath(raiz)
|
||
self.dicc = dicc
|
||
self.carp = {} # ruta (normcase) -> fila de `carpetas`
|
||
self.hijos_de = defaultdict(list) # ruta (normcase) -> rutas de sus subcarpetas
|
||
tope = os.path.normcase(self.raiz)
|
||
for c in con.execute("SELECT * FROM carpetas WHERE raiz = ?", (self.raiz,)).fetchall():
|
||
k = os.path.normcase(os.path.normpath(c["ruta"]))
|
||
self.carp[k] = c
|
||
if k != tope:
|
||
self.hijos_de[os.path.normcase(os.path.dirname(os.path.normpath(c["ruta"])))].append(os.path.normpath(c["ruta"]))
|
||
# por carpeta, contando lo de sus subcarpetas: [archivos, de cámara, de rodaje, proyectos]
|
||
self.cuenta = defaultdict(lambda: [0, 0, 0, 0])
|
||
self.anios = defaultdict(Counter) # ruta -> cuántos archivos de cada año hay debajo
|
||
for f in con.execute("SELECT dir, rol, fuente, substr(fecha, 1, 4) anio, COUNT(*) n FROM archivos WHERE raiz = ? "
|
||
"AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol, fuente, anio", (self.raiz,)).fetchall():
|
||
cam = f["fuente"] in R.FUENTE_CARPETA or f["rol"] in R.ROLES_CAMARA
|
||
rod = f["rol"] in R.ROLES_RODAJE
|
||
proy = f["rol"] in ("proyecto", "diseno")
|
||
d = os.path.normpath(f["dir"])
|
||
while True:
|
||
e = self.cuenta[os.path.normcase(d)]
|
||
e[0] += f["n"]
|
||
e[1] += f["n"] if cam else 0
|
||
e[2] += f["n"] if rod else 0
|
||
e[3] += f["n"] if proy else 0
|
||
if f["anio"]:
|
||
self.anios[os.path.normcase(d)][f["anio"]] += f["n"]
|
||
if os.path.normcase(d) == tope:
|
||
break
|
||
padre = os.path.dirname(d)
|
||
if padre == d:
|
||
break
|
||
d = padre
|
||
|
||
def _k(self, ruta):
|
||
return os.path.normcase(os.path.normpath(ruta))
|
||
|
||
def utiles(self, ruta):
|
||
"""Archivos que hay debajo, sin contar basura ni proxies."""
|
||
return self.cuenta[self._k(ruta)][0] if self._k(ruta) in self.cuenta else 0
|
||
|
||
def hijos(self, ruta):
|
||
return self.hijos_de.get(self._k(ruta), [])
|
||
|
||
def anio_de(self, ruta):
|
||
"""El año del que es la mayor parte de lo que hay debajo, o None si la carpeta está vacía."""
|
||
c = self.anios.get(self._k(ruta))
|
||
if not c:
|
||
return None
|
||
try:
|
||
return int(c.most_common(1)[0][0])
|
||
except ValueError:
|
||
return None
|
||
|
||
def debajo(self, ruta, hondo=3):
|
||
"""Las subcarpetas hasta `hondo` niveles."""
|
||
nivel = [os.path.normpath(ruta)]
|
||
for _ in range(hondo):
|
||
nivel = [h for p in nivel for h in self.hijos(p)]
|
||
if not nivel:
|
||
return
|
||
yield from nivel
|
||
|
||
def fechas_dentro(self, ruta, hondo=3):
|
||
"""Carpetas de año o de mes que hay debajo y tienen material: la señal de que esto guarda varias
|
||
cosas de distintas fechas y hay que mirarlo por dentro."""
|
||
return [d for d in self.debajo(ruta, hondo)
|
||
if self.utiles(d) and (R.detectar_anio(os.path.basename(d)) or _mes_claro(os.path.basename(d)))]
|
||
|
||
def partes(self, ruta, hondo=3):
|
||
"""Qué partes de una pieza montada hay entre sus subcarpetas (exportados, portada, stock, música…)."""
|
||
out = set()
|
||
for d in self.debajo(ruta, hondo):
|
||
if not self.utiles(d):
|
||
continue
|
||
cub, _ = R.cubeta_carpeta(os.path.basename(d), self.dicc)
|
||
if cub in R.PARTES_PIEZA:
|
||
out.add(cub)
|
||
return out
|
||
|
||
def es_rodaje(self, ruta, por_nombre=False):
|
||
"""¿Una tarjeta volcada, o una carpeta con algo montado dentro?
|
||
|
||
`por_nombre` es True cuando el nombre ya dice que es una grabación con una palabra de fábrica
|
||
(«Grabación», «URSA», «Tomas»): entonces basta con que casi todo sea material, aunque la cámara
|
||
no se reconozca. Si no, se exige que casi todo sean clips de cámara reconocidos."""
|
||
n, cam, rod, proy = self.cuenta[self._k(ruta)] if self._k(ruta) in self.cuenta else (0, 0, 0, 0)
|
||
if not n:
|
||
return por_nombre
|
||
if len(self.partes(ruta)) >= 2:
|
||
return False
|
||
return rod >= 0.6 * n if por_nombre else cam >= 0.8 * n
|
||
|
||
|
||
def _propias(tipo):
|
||
"""Los nombres de carpeta que ya son de la estructura para este tipo de cliente: los que van en la
|
||
raíz y los que van dentro de un mes. Salen de las plantillas en uso, no de una lista fija."""
|
||
primeros, en_mes = set(), set()
|
||
for p in PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).values():
|
||
segs = p.split("/")
|
||
if "{" not in segs[0]:
|
||
primeros.add(segs[0].lower())
|
||
for a, b in zip(segs, segs[1:]):
|
||
if a == "{mes}" and "{" not in b:
|
||
en_mes.add(b.lower())
|
||
return primeros, en_mes
|
||
|
||
|
||
def _es_propia(nombre, nivel, primeros, en_mes):
|
||
"""True si la carpeta ya es de la estructura y no hay que volver a ordenarla."""
|
||
if nombre.startswith("_") or R.norm(nombre) in R.CARPETAS_PROPIAS:
|
||
return True
|
||
n = nombre.lower()
|
||
return (nivel == "raiz" and n in primeros) or (nivel == "mes" and n in en_mes)
|
||
|
||
|
||
def _de_fabrica(nombre, cubeta):
|
||
"""True si el nombre dice esa cubeta por una palabra de fábrica («Grabación», «URSA») y no porque
|
||
alguien lo haya confirmado en «Por nombrar»."""
|
||
return R.cubeta_carpeta(nombre, R.DICCIONARIO_SEMILLA)[0] == cubeta
|
||
|
||
|
||
def _fecha_de_camara(f):
|
||
"""La fecha de modificación de un original de cámara es la del rodaje: nadie lo reescribe. No vale
|
||
para lo que sale del convertidor ni para lo que no se sabe de qué cámara viene."""
|
||
return (f["fuente"] in R.FUENTE_CARPETA
|
||
and f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara")
|
||
and not R.es_conversion(f["nombre"]))
|
||
|
||
|
||
_RX_DIA = re.compile(r"^\d{2}- (" + "|".join(R.DIAS) + r")$", re.I)
|
||
_RX_FECHA_DIA = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||
# en un banco de material, lo que se reordena por fecha y cámara: el material grabado, no lo montado
|
||
_ROLES_BANCO = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video")
|
||
# carpetas que solo dicen con qué se grabó (además de las de R.FUENTE_PALABRAS)
|
||
_CAMARAS_BANCO = {"fuji", "gopro", "go pro", "mavic", "osmos", "insta360", "iphone", "ronin", "fx3", "fx6", "a7", "a7s"}
|
||
_NOMBRES_FUENTE = {v.upper() for v in R.FUENTE_CARPETA.values()} | {"AUDIO", "OTROS", "FOTOS"}
|
||
|
||
|
||
def _solo_fuente(seg):
|
||
"""True si la carpeta solo dice la cámara, la tarjeta, el día o que son repetidos: no es un tema."""
|
||
s = (seg or "").strip()
|
||
n = R.norm(s)
|
||
return (n in R.FUENTE_PALABRAS or n in _CAMARAS_BANCO or R.es_carpeta_de_tarjeta(s) or bool(_RX_DIA.match(s))
|
||
or bool(_RX_FECHA_DIA.match(s)) or s.upper() in _NOMBRES_FUENTE or n == R.norm(R.DUPLICADOS))
|
||
|
||
|
||
def _bajo_alguna(ruta, conjunto):
|
||
"""True si `ruta` o alguna carpeta por encima de ella está en `conjunto` (rutas en minúsculas)."""
|
||
d = os.path.normpath(ruta).lower()
|
||
while True:
|
||
if d in conjunto:
|
||
return True
|
||
p = os.path.dirname(d)
|
||
if p == d:
|
||
return False
|
||
d = p
|
||
|
||
|
||
def _tallo(nombre):
|
||
"""«C0001_Proxy.mov», «C0001S03.MP4» (el proxy que graba la Sony en M4ROOT/SUB) y «C0001.MP4» -> «c0001»:
|
||
para emparejar un proxy con su original."""
|
||
t = re.sub(r"[ _.-]?proxy$", "", os.path.splitext(nombre or "")[0].lower())
|
||
m = re.match(r"^(c\d{4})s\d{2}$", t)
|
||
return m.group(1) if m else t
|
||
|
||
|
||
# ==========================================================================
|
||
# 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.bancos = [] # (ruta normcase + sep, nombre, protegido): los bancos de material
|
||
self.bancos_info = [] # lo mismo para la interfaz: nombre, si dice «No tocar», y cómo se ordena
|
||
self._carpeta_existe = {} # destino -> si ya existe en el disco (para no preguntar dos veces)
|
||
self._bytes = None # ruta -> bytes, para partir filas de archivos
|
||
self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos
|
||
self.con = db()
|
||
self.avisos = []
|
||
self.dias_mes = self._dias_de_rodaje()
|
||
self.est = Estructura(self.con, self.raiz, self.dicc)
|
||
# Las carpetas que ya son de la estructura salen de las plantillas de este tipo de cliente, no de
|
||
# una lista fija: «Exportados» es canónica en la raíz de un cliente interno y no lo es en la de
|
||
# uno de contenido, donde lo canónico es «_Exportados» dentro del mes.
|
||
pl = PLANTILLAS.get(self.tipo, PLANTILLAS["contenido"])
|
||
self.primeros, self.en_mes = _propias(self.tipo)
|
||
self.por_mes = {c for c, p in pl.items() if "{mes}" in p} # lo que en este tipo de cliente va dentro del mes
|
||
self.cajon = 0 # cuántos cajones hay abiertos por encima de donde se está mirando
|
||
# Abrir una carpeta porque guarda meses dentro solo tiene sentido donde las piezas se ordenan por
|
||
# mes. En un cliente de campañas o de un proyecto puntual, «Campaña X\2025\Noviembre» es una
|
||
# campaña con sus fechas, no un cajón: se mueve entera.
|
||
self.abre_cajones = "{mes}" in (pl.get("PIEZA") or "")
|
||
# dónde vive el material ya ordenado: «{anio}/{mes}/00_Grabacion», «Material/Grabaciones/{anio}/{mes}»…
|
||
segs = (pl.get("GRABACION") or "").split("/")
|
||
while segs and segs[-1] in ("{dia}", "{sub}", "{fuente}", "{nombre}"):
|
||
segs.pop()
|
||
self.patron_grab = segs if "{mes}" in segs and "{anio}" in segs else []
|
||
|
||
def _dias_de_rodaje(self):
|
||
"""Para cada mes, los días distintos en que se grabó algo en esta carpeta de cliente.
|
||
|
||
Sirve para decidir si el mes necesita carpetas por día. Medido el 16-sep-2026 sobre el material
|
||
real: David Reyes tiene un solo día de rodaje en 7 de sus 8 meses (ahí la carpeta del día sería
|
||
estorbo), mientras que Danney tiene 36 meses con varios días y Camilo llega a 13 días distintos
|
||
en un mismo mes, 907 archivos mezclados en la misma carpeta."""
|
||
dias = defaultdict(set)
|
||
for f in self.con.execute(
|
||
"SELECT fecha FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN "
|
||
"('camara_raw','camara_video','camara_sidecar','audio_grabado','celular_video')", (self.raiz,)):
|
||
f0 = f["fecha"]
|
||
try:
|
||
dias[(int(f0[:4]), int(f0[5:7]))].add(f0)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return dias
|
||
|
||
def dia_de(self, fecha, anio, mes):
|
||
"""El nombre de la carpeta del día de rodaje («13- Jueves»).
|
||
|
||
Hasta la 3.5.0 se omitía cuando ese mes solo tenía un día de rodaje. Munka, 14-oct-2026: las
|
||
grabaciones van «siempre según la cámara, organizado por fecha y por carpetas», así que la carpeta
|
||
del día va siempre, y dentro una por fuente (URSA_BRAW, SONY, DJI_MIC, TASCAM…)."""
|
||
return R.nombre_dia(fecha)
|
||
|
||
# ---- helpers ----
|
||
def rel(self, ruta):
|
||
return os.path.relpath(ruta, self.raiz)
|
||
|
||
def abs_(self, rel):
|
||
return os.path.normpath(os.path.join(self.raiz, rel))
|
||
|
||
def regla_para(self, ruta):
|
||
rel = R.norm(self.rel(ruta))
|
||
for r in self.reglas:
|
||
if R.norm(r.get("origen", "")) == rel:
|
||
return r
|
||
return None
|
||
|
||
def _hay_carpeta(self, d):
|
||
d = os.path.normcase(os.path.normpath(d))
|
||
if d not in self._carpeta_existe:
|
||
self._carpeta_existe[d] = os.path.isdir(d)
|
||
return self._carpeta_existe[d]
|
||
|
||
def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None,
|
||
marcado=None, kw_destinos=None, paso=None):
|
||
destino = os.path.normpath(destino)
|
||
if archivos is None and os.path.normpath(origen).lower() == destino.lower():
|
||
# el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» ->
|
||
# «10- Octubre»); antes se descartaba también ese, y el plan prometía meses «con nombre
|
||
# completo» que nunca se normalizaban
|
||
if accion != "renombrar" or os.path.normpath(origen) == destino:
|
||
return
|
||
solo_mayusculas = os.path.normpath(origen).lower() == destino.lower()
|
||
if n is None or bytes_ is None:
|
||
if archivos is not None:
|
||
n = len(archivos)
|
||
bytes_ = sum(a[1] for a in archivos)
|
||
else:
|
||
fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone()
|
||
n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0)
|
||
aviso_colision = ""
|
||
# Lo que hay que preguntarle al disco (¿ya hay en el destino un archivo con ese nombre?, ¿la carpeta
|
||
# destino existe?) se apunta y se pregunta al final, todo a la vez y en paralelo (`comprobar_destinos`).
|
||
# Medido el 14-oct-2026 en Vía Sumapaz, después de aplicar: 1.362 preguntas una detrás de otra, a 24-46 ms
|
||
# cada una con el NAS ocupado; 43 de los 58 segundos que tardaba «Calcular de nuevo».
|
||
chk = None
|
||
if archivos is not None and accion != "renombrar":
|
||
# solo los primeros archivos de cada fila: para avisar basta con encontrar uno
|
||
chk = {"tipo": "archivos", "primeros": [a[0] for a in archivos[:40]], "kw": dict(kw_destinos or {})}
|
||
elif archivos is None and accion != "renombrar":
|
||
chk = {"tipo": "carpeta", "fusion": not solo_mayusculas}
|
||
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": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
|
||
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"),
|
||
"_chk": chk,
|
||
})
|
||
|
||
def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
|
||
clave = (cubeta, destino_rel, motivo, confianza, os.path.normcase(os.path.dirname(fila["ruta"])))
|
||
self.sueltos[clave].append((fila["ruta"], fila["bytes"]))
|
||
|
||
def volcar_sueltos(self):
|
||
for (cubeta, destino_rel, motivo, confianza, _dir), archivos in self.sueltos.items():
|
||
origen = os.path.dirname(archivos[0][0])
|
||
self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos)
|
||
self.sueltos.clear()
|
||
|
||
def es_propia(self, nombre, nivel):
|
||
return _es_propia(nombre, nivel, self.primeros, self.en_mes)
|
||
|
||
def pieza_en(self, carpeta_mes, titulo, salvo=None):
|
||
"""La pieza con ese título que ya hay en un mes («02- Asimetría» para «Asimetría»), o None.
|
||
Así el material de una pieza que estaba en otro sitio se junta con ella en vez de abrir una
|
||
carpeta gemela al lado."""
|
||
t = R.norm(titulo)
|
||
if not t:
|
||
return None
|
||
|
||
def rango(ruta):
|
||
# cuál de dos gemelas se queda: la que ya tiene el nombre de la casa, luego la de número más bajo
|
||
nombre = os.path.basename(ruta)
|
||
p = R.detectar_pieza(nombre)
|
||
canonica = bool(p) and nombre == R.nombre_pieza(p[0], limpiar_nombre(p[1]))
|
||
return (0 if canonica else 1, p[0] if p else 99, nombre.lower())
|
||
|
||
mejor = None
|
||
for h in self.est.hijos(carpeta_mes):
|
||
if salvo and os.path.normcase(h) == os.path.normcase(salvo):
|
||
continue
|
||
nombre = os.path.basename(h)
|
||
p = R.detectar_pieza(nombre)
|
||
if R.norm(p[1] if p else nombre) == t and os.path.isdir(h) and (mejor is None or rango(h) < rango(mejor)):
|
||
mejor = h
|
||
# si las dos están en la misma carpeta, solo se mueve una: la otra es la que se queda y no debe
|
||
# proponer a su vez irse con la primera
|
||
if mejor and salvo and os.path.normcase(os.path.dirname(mejor)) == os.path.normcase(os.path.dirname(salvo)) \
|
||
and rango(salvo) < rango(mejor):
|
||
return None
|
||
return mejor
|
||
|
||
# ---- niveles ----
|
||
def correr(self):
|
||
esc = self.con.execute("SELECT * FROM escaneos WHERE raiz = ?", (self.raiz,)).fetchone()
|
||
if esc and (esc["reglas"] or 0) < R.VERSION_REGLAS:
|
||
self.avisos.append("El inventario de esta carpeta se hizo con una versión anterior del programa, que "
|
||
"reconocía peor las cámaras y las fechas. Escanéala otra vez antes de aplicar nada.")
|
||
self.plan_dir(self.raiz, "raiz", None, None)
|
||
self.plan_auditoria()
|
||
self.volcar_sueltos()
|
||
self.plan_basura()
|
||
self.comprobar_destinos()
|
||
self.revisar_proxies()
|
||
self.proteger_bancos()
|
||
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,
|
||
"bancos": self.bancos_info}
|
||
|
||
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
|
||
if otra is f:
|
||
self.avisar_repetido(f, 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_repetido(self, fila, que):
|
||
aviso = f" · OJO: dentro de «{fila['origen_rel']}» hay dos archivos «{que}» que acabarían en la misma carpeta"
|
||
if aviso not in fila["motivo"]:
|
||
fila["motivo"] += aviso
|
||
fila["confianza"] = "baja"
|
||
fila["marcado"] = False
|
||
self.avisos.append(f"En «{fila['origen_rel']}» hay dos archivos que se llaman «{que}» (por ejemplo, la misma foto "
|
||
f"en dos tamaños) y los dos irían a «{fila['destino_rel']}». Uno no cabría: la fila queda sin "
|
||
f"marcar para que lo mires antes.")
|
||
|
||
def avisar_colision(self, fila, otra, que):
|
||
aviso = f" · OJO: «{otra['origen_rel']}» también va a «{fila['destino_rel']}» y las dos traen «{que}»"
|
||
if aviso not in fila["motivo"]:
|
||
fila["motivo"] += aviso
|
||
fila["confianza"] = "baja"
|
||
fila["marcado"] = False
|
||
self.avisos.append(f"«{fila['origen_rel']}» y «{otra['origen_rel']}» acabarían las dos en "
|
||
f"«{fila['destino_rel']}» con archivos del mismo nombre ({que}), y dos archivos que se "
|
||
f"llaman igual no caben en la misma carpeta. La segunda se mueve; la primera queda sin "
|
||
f"marcar para que decidas qué versión se queda.")
|
||
|
||
def entradas(self, ruta):
|
||
try:
|
||
with os.scandir(ruta) as it:
|
||
ents = [e for e in it if e.name.lower() not in ("#recycle", "@eadir")]
|
||
except OSError as ex:
|
||
self.avisos.append(f"No se pudo leer {ruta}: {ex}")
|
||
return [], []
|
||
dirs = sorted([e for e in ents if e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower())
|
||
files = sorted([e for e in ents if not e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower())
|
||
return dirs, files
|
||
|
||
def plan_dir(self, ruta, nivel, anio, mes, mes_destino=None):
|
||
"""`mes_destino` es la carpeta de mes a la que va a parar `ruta` cuando ella misma se mueve (un mes
|
||
suelto que entra en su año): sirve para juntar sus piezas con las que ya haya allí."""
|
||
dirs, files = self.entradas(ruta)
|
||
# numeración de piezas dentro de un mes
|
||
num = {"max": 0}
|
||
if nivel == "mes":
|
||
numeros = defaultdict(list)
|
||
for e in dirs:
|
||
p = R.detectar_pieza(e.name)
|
||
if p:
|
||
num["max"] = max(num["max"], p[0])
|
||
numeros[p[0]].append(e.name)
|
||
for n, nombres in sorted(numeros.items()):
|
||
if len({R.norm(R.detectar_pieza(x)[1]) for x in nombres}) > 1:
|
||
self.avisos.append(f"En «{self.rel(ruta)}» hay {len(nombres)} piezas con el número {n:02d}: "
|
||
+ ", ".join(f"«{x}»" for x in nombres)
|
||
+ ". La app no las renumera sola: cámbiale el número a una.")
|
||
for e in dirs:
|
||
self.plan_subdir(e, nivel, anio, mes, ruta, num, mes_destino)
|
||
for e in files:
|
||
self.plan_archivo(e, nivel, anio, mes, ruta)
|
||
|
||
def abrir(self, e):
|
||
"""Una carpeta que guarda meses o años dentro no es una cosa que mover: es un cajón. Se abre, y
|
||
lo de dentro se ordena por su cuenta, sin heredar el mes en el que el cajón estuviera metido."""
|
||
if not self.cajon: # un cajón dentro de otro no hace falta contarlo otra vez
|
||
dentro = self.est.fechas_dentro(e.path)
|
||
nombres = list(dict.fromkeys(os.path.basename(d) for d in dentro))
|
||
self.avisos.append(f"«{self.rel(e.path)}» guarda dentro carpetas de otras fechas ("
|
||
+ ", ".join(f"«{x}»" for x in nombres[:3]) + ("…" if len(nombres) > 3 else "")
|
||
+ "): la app la abre y ordena lo de dentro, cada cosa a su mes.")
|
||
self.cajon += 1
|
||
try:
|
||
self.plan_dir(e.path, "contenedor", None, None)
|
||
finally:
|
||
self.cajon -= 1
|
||
|
||
def plan_subdir(self, e, nivel, anio, mes, padre, num, mes_destino=None):
|
||
nombre = e.name
|
||
nn = R.norm(nombre)
|
||
regla = self.regla_para(e.path)
|
||
if regla:
|
||
if regla.get("destino") in ("", None, "IGNORAR"):
|
||
return
|
||
self.agregar(e.path, self.abs_(regla["destino"]), regla.get("cubeta", "REVISAR"), "mover carpeta",
|
||
"regla aprendida", "alta")
|
||
return
|
||
if nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA):
|
||
# la bandeja de entrada: se abre siempre, y lo de dentro se fecha solo
|
||
self.cajon += 1
|
||
try:
|
||
self.plan_dir(e.path, "contenedor", None, None)
|
||
finally:
|
||
self.cajon -= 1
|
||
return
|
||
if self.es_propia(nombre, nivel):
|
||
return # estructura ya canónica
|
||
if R.es_basura_dir(nombre):
|
||
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "BASURA", rel=self.rel(e.path))),
|
||
"BASURA", "papelera", "carpeta de caché / autoguardado", "alta")
|
||
return
|
||
if R.es_proxy_dir(nombre):
|
||
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "PROXY", rel=self.rel(e.path))),
|
||
"PROXY", "papelera", "proxies: se regeneran desde Resolve/Premiere", "alta")
|
||
return
|
||
a = R.detectar_anio(nombre)
|
||
if a and nivel in ("raiz", "contenedor", "grabacion"):
|
||
if nivel == "raiz":
|
||
self.plan_dir(e.path, "anio", a, None)
|
||
else:
|
||
# un año dentro de un contenedor: mirar adentro con el año fijado
|
||
self.plan_dir(e.path, "contenedor", a, None)
|
||
return
|
||
if nivel == "anio":
|
||
m, conflicto = R.detectar_mes(nombre)
|
||
if m:
|
||
canon = R.nombre_mes(m)
|
||
real, real_origen = self.mes_real(e.path)
|
||
discrepa = bool(real and anio and _discrepa_mes("%04d-%02d-01" % real, real_origen, anio, m))
|
||
if nombre != canon:
|
||
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "")
|
||
if discrepa:
|
||
motivo += (f" · ojo: lo que hay dentro se grabó en {R.nombre_mes(real[1])}, y se archiva "
|
||
f"en ese mes aunque la carpeta se llame así")
|
||
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo,
|
||
"media" if (conflicto or discrepa) else "alta")
|
||
self.plan_dir(e.path, "mes", anio, m)
|
||
return
|
||
if nivel == "raiz" or (nivel == "contenedor" and not anio):
|
||
# un mes suelto ("marzo", "Marzo 2026", "03- Marzo") fuera de su carpeta de año
|
||
sin_anio = re.sub(r"20\d\d", " ", nombre).strip(" -_")
|
||
m, conflicto = R.detectar_mes(sin_anio)
|
||
if m:
|
||
m_anio = re.search(r"(20\d\d)", nombre)
|
||
# el año, por este orden: el que diga el nombre, el de la mayoría de lo que hay dentro
|
||
fa = int(m_anio.group(1)) if m_anio else (self.est.anio_de(e.path) or fecha_carpeta(self.con, e.path)[0])
|
||
hoy = datetime.date.today()
|
||
if fa and (fa, m) > (hoy.year, hoy.month):
|
||
fa -= 1
|
||
if fa:
|
||
canon = self.abs_(os.path.join(str(fa), R.nombre_mes(m)))
|
||
self.agregar(e.path, canon, "MES", "mover carpeta",
|
||
f"mes suelto fuera de su año: entra en {fa}", "media")
|
||
self.plan_dir(e.path, "mes", fa, m, mes_destino=canon)
|
||
return
|
||
if nivel == "contenedor" and anio:
|
||
m, conflicto = R.detectar_mes(nombre)
|
||
if m:
|
||
# un mes guardado dentro de "Nuevo 2026": sale al año que le toca
|
||
canon = self.abs_(os.path.join(str(anio), R.nombre_mes(m)))
|
||
self.agregar(e.path, canon, "MES", "mover carpeta", f"mes guardado dentro de «{os.path.basename(padre)}»", "media")
|
||
self.plan_dir(e.path, "mes", anio, m, mes_destino=canon)
|
||
return
|
||
if nivel == "mes":
|
||
p = R.detectar_pieza(nombre)
|
||
if p:
|
||
cub_t, _ = R.cubeta_carpeta(p[1], self.dicc)
|
||
# "03 - Grabacion Agosto": es la sesión del mes, no una pieza. Pero solo si de verdad es
|
||
# una tarjeta volcada: una pieza a la que alguien llamó grabación no se desarma.
|
||
if cub_t == "GRABACION" and self.est.es_rodaje(e.path, por_nombre=_de_fabrica(p[1], "GRABACION")):
|
||
self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación")
|
||
return
|
||
if cub_t in ("GRABACION", "CONTENEDOR") and self.est.fechas_dentro(e.path):
|
||
self.abrir(e) # no es una pieza: es un cajón con cosas de varios meses
|
||
return
|
||
canon = R.nombre_pieza(p[0], limpiar_nombre(p[1]))
|
||
gemela = self.pieza_en(mes_destino or padre, p[1], salvo=e.path)
|
||
if gemela and os.path.basename(gemela) != nombre:
|
||
self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar",
|
||
f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media")
|
||
elif nombre != canon:
|
||
self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar",
|
||
"cosmético: nombre de pieza a «NN- Título» (sin marcar por defecto)", "media", marcado=False)
|
||
if cub_t != "PIEZA" and self.est.fechas_dentro(e.path):
|
||
self.avisos.append(f"«{self.rel(e.path)}» está numerada como una pieza, pero dentro guarda carpetas "
|
||
f"de varias fechas. Si es un cajón con cosas de distintos meses, dilo en «Por "
|
||
f"nombrar» y la app lo abrirá para ordenar lo de dentro.")
|
||
return # dentro de una pieza no se toca
|
||
cub, motivo = R.cubeta_carpeta(nombre, self.dicc)
|
||
if cub == "IGNORAR":
|
||
return
|
||
if cub == "BANCO":
|
||
self.plan_banco(e)
|
||
return
|
||
if cub == "CONTENEDOR":
|
||
m_anio = re.search(r"(20\d\d)", nombre)
|
||
if self.est.fechas_dentro(e.path):
|
||
# trae sus propios meses o años: lo de dentro se fecha solo, sin heredar el mes de fuera
|
||
self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else None, None)
|
||
else:
|
||
self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else anio, mes)
|
||
return
|
||
if cub == "REVISAR":
|
||
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "REVISAR", nombre=nombre)), "REVISAR",
|
||
"mover carpeta", motivo, "baja")
|
||
return
|
||
montada = ""
|
||
if cub == "GRABACION":
|
||
if self.est.es_rodaje(e.path, por_nombre=_de_fabrica(nombre, "GRABACION")):
|
||
self.plan_grabacion(e, nivel, anio, mes, motivo)
|
||
return
|
||
# se llama (o alguien dijo que era) una grabación, pero dentro hay proyectos, exportados o
|
||
# portadas: desarmarla por cámara y por mes deshace un trabajo montado
|
||
partes = self.est.partes(e.path)
|
||
montada = ("tiene nombre de grabación, pero dentro hay algo montado ("
|
||
+ ", ".join(sorted(_PARTE_FRASE.get(x, x.lower()) for x in partes)) + "): no se desarma"
|
||
if len(partes) >= 2 else
|
||
"tiene nombre de grabación, pero casi nada de lo que hay dentro es material de cámara: no se reparte")
|
||
cub = None
|
||
# La forma manda sobre el nombre: una carpeta con meses o años dentro se abre, aunque se llame
|
||
# «Proyectos» o «Edición». Vale para lo que no tiene sitio propio y para lo que en este tipo de
|
||
# cliente va dentro de cada mes (exportados, portadas…); la música o el stock no van por fecha.
|
||
if self.abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in self.por_mes) \
|
||
and self.est.fechas_dentro(e.path) and (montada or cub is not None or not self.est.es_rodaje(e.path)):
|
||
self.abrir(e)
|
||
return
|
||
generico = nn in self.dicc and not montada
|
||
limpio = limpiar_nombre(nombre)
|
||
p = R.detectar_pieza(nombre)
|
||
if p and cub in ("CAMPANA", "PIEZA", None):
|
||
limpio = limpiar_nombre(p[1]) # "01- Dove" -> "Dove"
|
||
sub = "" if generico else limpio
|
||
fa, fm, fconf, fmot = fecha_carpeta(self.con, e.path)
|
||
con_fecha = cub in ("PIEZA", "CAMPANA", "EXPORTADOS", "PORTADAS", "VOZ", "IA", "GUION", "FOTOS", "CELULAR", None)
|
||
if anio:
|
||
fa = anio
|
||
if mes:
|
||
fm = mes
|
||
elif fconf == "baja" and cub not in ("PIEZA", None):
|
||
fm = None # fechas mezcladas: se queda a nivel de año ("2025/_Exportados")
|
||
fconf = "media" if fa else "baja"
|
||
hoy = datetime.date.today()
|
||
if fa and fm and (fa, fm) > (hoy.year, hoy.month):
|
||
fa -= 1 # un mes en el futuro no existe: era del año pasado
|
||
if cub is None:
|
||
if not montada and self.est.es_rodaje(e.path):
|
||
self.plan_grabacion(e, nivel, anio, mes, "carpeta sin nombre reconocido pero llena de clips de cámara")
|
||
return
|
||
if nivel == "mes":
|
||
gemela = self.pieza_en(mes_destino or padre, limpio, salvo=e.path)
|
||
if gemela:
|
||
self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar",
|
||
f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media")
|
||
return
|
||
num["max"] += 1
|
||
canon = R.nombre_pieza(num["max"], limpio)
|
||
self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar",
|
||
"carpeta sin número dentro del mes: se numera como pieza", "media")
|
||
return
|
||
cub = "PIEZA"
|
||
motivo = montada or "no reconocida: se propone como pieza por la fecha de sus archivos"
|
||
fconf = "baja"
|
||
sub = limpio
|
||
if con_fecha and not (anio and mes):
|
||
conf = fconf
|
||
motivo = f"{motivo}; {fmot}"
|
||
else:
|
||
conf = "alta" if generico else "media"
|
||
if cub == "MARCA":
|
||
sub = R.MARCA_SUB.get(nn, sub or limpio)
|
||
conf = "alta" if generico or nn in R.MARCA_SUB else "media"
|
||
if cub in ("PIEZA", "CAMPANA", "PROYECTOS"):
|
||
sub = sub or limpio
|
||
destino = self.abs_(destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path)))
|
||
if cub == "PIEZA":
|
||
gemela = self.pieza_en(os.path.dirname(destino), limpio, salvo=e.path)
|
||
if gemela:
|
||
destino = gemela
|
||
motivo += f" · la misma pieza ya está en ese mes: se junta con «{os.path.basename(gemela)}»"
|
||
self.agregar(e.path, destino, cub, "mover carpeta", motivo, conf)
|
||
|
||
def mes_real(self, ruta):
|
||
"""((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de
|
||
acuerdo. (None, "") cuando la fecha solo sale de la fecha de modificación, que no es de fiar."""
|
||
cnt = Counter()
|
||
origenes = Counter()
|
||
for f in archivos_bajo(self.con, ruta):
|
||
if f["es_basura"] or f["es_proxy"] or f["fecha_origen"] == "mtime":
|
||
continue
|
||
if f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "foto_camara", "celular_video", "audio_grabado"):
|
||
cnt[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
|
||
origenes[f["fecha_origen"]] += 1
|
||
if not cnt:
|
||
return None, ""
|
||
(a, m), n = cnt.most_common(1)[0]
|
||
if n < 3 or n / sum(cnt.values()) < 0.7:
|
||
return None, ""
|
||
return (a, m), origenes.most_common(1)[0][0]
|
||
|
||
def plan_banco(self, e):
|
||
"""Un banco de material por temas («Material Vía (No tocar)\\Lugares\\Peajes»): el cliente lo consulta
|
||
por tema, así que los temas se quedan donde están. Lo que se ordena son las grabaciones de dentro de
|
||
cada tema: por fecha de rodaje y por cámara, como en cualquier 00_Grabacion. «Lugares\\Peajes\\Drone»
|
||
pasa a «Lugares\\Peajes\\2024-03-12\\DJI». Las carpetas que solo dicen la cámara, la tarjeta o el día
|
||
(«Drone», «URSA», «Pocket», «100MEDIA», «Grabado») no son un tema: se funden en el tema de encima.
|
||
|
||
Medido el 14-oct-2026 en Vía Sumapaz: el banco eran 3,6 TB, 50.076 archivos, con temas como Lugares,
|
||
Social, Ambiental o Unidades Funcionales; tratado como piezas sueltas, el plan quería llevar cada tema
|
||
entero a un mes inventado. Si el nombre dice «No tocar», todo lo de dentro va sin marcar."""
|
||
protegido = R.es_no_tocar(e.name)
|
||
self.bancos.append((os.path.normcase(os.path.normpath(e.path)) + os.sep, e.name, protegido))
|
||
# cómo se ordena: por temas (lo de siempre) o por fecha, a la 00_Grabacion de su mes con el tema de etiqueta
|
||
modo = ((CONFIG.get("bancos") or {}).get(self.raiz.lower()) or {}).get(R.norm(e.name), "temas")
|
||
self.bancos_info.append({"nombre": e.name, "protegido": protegido, "modo": modo})
|
||
grupos = defaultdict(list)
|
||
for f in archivos_bajo(self.con, e.path):
|
||
if f["es_basura"] or f["es_proxy"] or f["rol"] not in _ROLES_BANCO or not f["fecha"]:
|
||
continue
|
||
rel = os.path.relpath(f["dir"], e.path)
|
||
segs = [] if rel == "." else rel.split(os.sep)
|
||
repetido = False
|
||
while segs and _solo_fuente(segs[-1]):
|
||
repetido = repetido or R.norm(segs[-1]) == R.norm(R.DUPLICADOS)
|
||
segs.pop()
|
||
fuente = R.FUENTE_CARPETA.get(f["fuente"]) or ("AUDIO" if f["rol"] == "audio_grabado" else "OTROS")
|
||
fiable = f["fecha_origen"] != "mtime" or _fecha_de_camara(f)
|
||
# una cámara con el reloj sin poner fecha en 2008 o 2009: medido en Vía Sumapaz, 250 clips de la
|
||
# Blackmagic «del 28 de julio de 2008» y 100 del dron «del 19 de enero de 2009»
|
||
reloj = f["fecha_origen"] not in ("nombre", "corregida") and int(f["fecha"][:4]) < 2014
|
||
tema = segs[-1] if segs else e.name
|
||
if modo == "fecha":
|
||
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
|
||
sub = os.path.join(*[x for x in (limpiar_nombre(tema), fuente, R.DUPLICADOS if repetido else "") if x])
|
||
destino = self.abs_(destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=self.dia_de(f["fecha"], ay, am),
|
||
sub=sub, nombre=limpiar_nombre(tema)))
|
||
else:
|
||
destino = os.path.join(e.path, *segs, f["fecha"][:10], fuente, *([R.DUPLICADOS] if repetido else []))
|
||
if os.path.normcase(os.path.normpath(destino)) == os.path.normcase(os.path.normpath(f["dir"])):
|
||
continue
|
||
por_que = "reloj" if reloj else "" if fiable else "mtime"
|
||
grupos[(f["dir"], destino, tema, "media" if fiable and not reloj else "baja", por_que)].append((f["ruta"], f["bytes"]))
|
||
for (origen, destino, tema, conf, por_que), archivos in sorted(grupos.items()):
|
||
self.agregar(origen, destino, "GRABACION", "mover archivos",
|
||
(f"banco de material «{e.name}», por fecha: a la grabación de su mes, con «{tema}» de etiqueta" if modo == "fecha" else
|
||
f"banco de material «{e.name}»: el tema «{tema}» se queda; dentro, por fecha de rodaje y cámara")
|
||
+ {"": "", "mtime": " (la fecha sale de la fecha de modificación: puede fallar)",
|
||
"reloj": f" (la fecha, {next((x for x in destino.split(os.sep) if _RX_FECHA_DIA.match(x)), '')}, parece de una cámara con el reloj sin poner: no es de fiar)"}[por_que],
|
||
conf, archivos=archivos, marcado=False if protegido else None, paso="banco")
|
||
self.filas[-1]["no_tocar"] = protegido
|
||
self.filas[-1]["banco"] = e.name
|
||
|
||
def comprobar_destinos(self):
|
||
"""Las comprobaciones contra el disco de todas las filas, a la vez: primero qué carpetas existen y luego,
|
||
solo en las que existen, qué archivos de destino ya están. Lo que choca queda sin marcar y dudoso."""
|
||
pend, dirs = [], set()
|
||
for f in self.filas:
|
||
chk = f.pop("_chk", None)
|
||
if not chk:
|
||
continue
|
||
if chk["tipo"] == "archivos":
|
||
esp = dict(f.get("destinos_archivo") or {})
|
||
esp.update(chk["kw"])
|
||
dests = [esp.get(a) or os.path.join(f["destino"], os.path.basename(a)) for a in chk["primeros"]]
|
||
dirs.update(os.path.dirname(d) for d in dests)
|
||
pend.append((f, "archivos", dests))
|
||
else:
|
||
dirs.update((f["destino"], f["origen"]))
|
||
pend.append((f, "carpeta", chk["fusion"]))
|
||
if not pend:
|
||
return
|
||
hay_dir = _existen(dirs)
|
||
candidatos = {d for f, t, x in pend if t == "archivos" for d in x if hay_dir.get(os.path.dirname(d))}
|
||
candidatos |= {f["destino"] for f, t, x in pend if t == "carpeta" and not hay_dir.get(f["destino"])}
|
||
hay_archivo = _son_archivos(candidatos)
|
||
for f, t, x in pend:
|
||
if t == "archivos":
|
||
choques = [os.path.basename(d) for d in x if hay_archivo.get(d)]
|
||
if choques:
|
||
f["motivo"] += f" · OJO: en el destino ya hay archivos con el mismo nombre («{choques[0]}»): esos no se moverían"
|
||
f["confianza"], f["marcado"] = "baja", False
|
||
elif hay_archivo.get(f["destino"]):
|
||
f["motivo"] += " · ya existe un archivo con ese nombre en el destino: no se moverá"
|
||
f["confianza"], f["marcado"] = "baja", False
|
||
elif x and hay_dir.get(f["destino"]) and hay_dir.get(f["origen"]):
|
||
f["motivo"] += " · la carpeta destino ya existe: se fusionan"
|
||
|
||
def _partir(self, f, aparte, motivo, confianza=None, no_tocar=False):
|
||
"""Saca de una fila de archivos los que cumplen `aparte` a una fila propia, sin marcar. Si son todos,
|
||
la fila entera queda sin marcar. Devuelve la fila nueva, si hace falta una."""
|
||
sep = [a for a in f["archivos"] if aparte(a)]
|
||
if not sep:
|
||
return None
|
||
if len(sep) == len(f["archivos"]):
|
||
f["marcado"] = False
|
||
f["no_tocar"] = f.get("no_tocar") or no_tocar
|
||
f["confianza"] = confianza or f["confianza"]
|
||
if motivo not in f["motivo"]:
|
||
f["motivo"] += motivo
|
||
return None
|
||
if self._bytes is None:
|
||
self._bytes = {r["ruta"]: r["bytes"] or 0 for r in self.con.execute("SELECT ruta, bytes FROM archivos WHERE raiz = ?", (self.raiz,))}
|
||
quedan = [a for a in f["archivos"] if not aparte(a)]
|
||
nueva = dict(f)
|
||
destinos = dict(f.get("destinos_archivo") or {})
|
||
for fila, lista in ((f, quedan), (nueva, sep)):
|
||
fila["archivos"] = lista
|
||
fila["n"] = len(lista)
|
||
fila["bytes"] = sum(self._bytes.get(a, 0) for a in lista)
|
||
if destinos:
|
||
fila["destinos_archivo"] = {a: destinos[a] for a in lista if a in destinos}
|
||
nueva.update(marcado=False, confianza=confianza or f["confianza"], motivo=f["motivo"] + motivo, cosmetico=False,
|
||
no_tocar=f.get("no_tocar") or no_tocar)
|
||
return nueva
|
||
|
||
def revisar_proxies(self):
|
||
"""Un proxy sin su original puede ser la única copia que queda de ese clip. Medido el 14-oct-2026 en Vía
|
||
Sumapaz: de 12.645 proxies (449 GB), 126 (4,4 GB) no tenían el original en ningún sitio de la carpeta
|
||
(clips Fuji de una carpeta «Proxy», el proxy de un reel cuyo master ya no está, .LRF de un dron sin su
|
||
MP4). Esos van aparte y sin marcar; los demás siguen yendo a _Para borrar como siempre."""
|
||
filas = [f for f in self.filas if f["cubeta"] == "PROXY"]
|
||
if not filas:
|
||
return
|
||
originales = {_tallo(r["nombre"]) for r in self.con.execute(
|
||
"SELECT nombre FROM archivos WHERE raiz = ? AND es_proxy = 0 AND es_basura = 0", (self.raiz,))}
|
||
|
||
def huerfano(ruta):
|
||
n = os.path.basename(ruta)
|
||
ext = os.path.splitext(n)[1].lower()
|
||
return (ext in R.EXT["video"] or ext == ".lrf") and _tallo(n) not in originales
|
||
bajo = defaultdict(list) # carpeta (minúsculas) -> los proxies que tiene debajo
|
||
tope = self.raiz.lower()
|
||
for r in self.con.execute("SELECT ruta, nombre, bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", (self.raiz,)).fetchall():
|
||
d = os.path.dirname(r["ruta"]).lower()
|
||
while True:
|
||
bajo[d].append(r)
|
||
p = os.path.dirname(d)
|
||
if d == tope or p == d:
|
||
break
|
||
d = p
|
||
nuevas, n_sin, b_sin = [], 0, 0
|
||
for f in filas:
|
||
if f["archivos"]:
|
||
sin = [a for a in f["archivos"] if huerfano(a)]
|
||
if not sin:
|
||
continue
|
||
nueva = self._partir(f, huerfano, f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la "
|
||
f"carpeta (p. ej. «{os.path.basename(sin[0])}»): puede ser la única copia; míralos antes", "baja")
|
||
if nueva:
|
||
nuevas.append(nueva)
|
||
n_sin += len(sin)
|
||
else:
|
||
sin = [r for r in bajo.get(os.path.normpath(f["origen"]).lower(), []) if huerfano(r["ruta"])]
|
||
if not sin:
|
||
continue
|
||
f["motivo"] += (f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la carpeta "
|
||
f"(p. ej. «{sin[0]['nombre']}»): puede ser la única copia; míralos antes")
|
||
f["confianza"], f["marcado"] = "baja", False
|
||
n_sin += len(sin)
|
||
b_sin += sum(r["bytes"] or 0 for r in sin)
|
||
self.filas += nuevas
|
||
if n_sin:
|
||
self.avisos.append(f"{_plural(n_sin, 'proxy no tiene', 'proxies no tienen')} su original en ningún sitio de esta carpeta. "
|
||
f"Pueden ser la única copia de esos clips: van sin marcar, para que los mires antes de apartarlos.")
|
||
|
||
def proteger_bancos(self):
|
||
"""Todo lo que el plan propone dentro de una carpeta «No tocar» (sus proxies, su basura) va sin marcar."""
|
||
prot = [(p, nombre) for p, nombre, protegido in self.bancos if protegido]
|
||
if not prot:
|
||
return
|
||
|
||
def de_cual(ruta):
|
||
rc = os.path.normcase(os.path.normpath(ruta))
|
||
return next((nombre for p, nombre in prot if rc.startswith(p)), None)
|
||
nuevas = []
|
||
for f in list(self.filas):
|
||
if f.get("paso") == "banco":
|
||
continue
|
||
if f["archivos"]:
|
||
dentro = [a for a in f["archivos"] if de_cual(a)]
|
||
if dentro:
|
||
nueva = self._partir(f, lambda a: de_cual(a) is not None,
|
||
f" · dentro de «{de_cual(dentro[0])}», que dice «No tocar»: márcalo tú si quieres", no_tocar=True)
|
||
if nueva:
|
||
nuevas.append(nueva)
|
||
else:
|
||
nombre = de_cual(f["origen"])
|
||
if nombre:
|
||
f["marcado"] = False
|
||
f["no_tocar"] = True
|
||
f["motivo"] += f" · dentro de «{nombre}», que dice «No tocar»: márcalo tú si quieres"
|
||
self.filas += nuevas
|
||
|
||
def plan_grabacion(self, e, nivel, anio, mes, motivo):
|
||
"""Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
|
||
nombre = e.name
|
||
nn = R.norm(nombre)
|
||
# solo los nombres de fábrica («Grabación», «URSA») son genéricos: «Videos Maquinas», aunque
|
||
# alguien haya confirmado que es una grabación, sigue siendo el nombre de esa sesión
|
||
generico = (nn in R.DICCIONARIO_SEMILLA or nn in ("grabacion", "grabado", "grabaciones")
|
||
or R.es_carpeta_de_tarjeta(nombre)) # «DCIM», «PRIVATE»: la tarjeta tal cual
|
||
filas = archivos_bajo(self.con, e.path)
|
||
if not filas:
|
||
return
|
||
etiqueta_base = "" if generico else R.etiqueta_grabacion(nombre)
|
||
# Subcarpetas que no son rodaje (los exportados de esa sesión, su música, un proyecto): cada una
|
||
# va a lo suyo en vez de acabar revuelta en 00_Grabacion\OTROS.
|
||
aparte = []
|
||
subdirs, _ = self.entradas(e.path)
|
||
for d in subdirs:
|
||
cub_d, _m = R.cubeta_carpeta(d.name, self.dicc)
|
||
if cub_d in R.PARTES_PIEZA and self.est.utiles(d.path):
|
||
self.plan_subdir(d, "contenedor", anio, mes, e.path, {"max": 0})
|
||
aparte.append(os.path.normcase(d.path) + os.sep)
|
||
grupos = defaultdict(list)
|
||
desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
|
||
aqui = os.path.normcase(os.path.normpath(e.path))
|
||
for f in filas:
|
||
if f["es_basura"] or f["es_proxy"]:
|
||
continue
|
||
if aparte and (os.path.normcase(f["dir"]) + os.sep).startswith(tuple(aparte)):
|
||
continue
|
||
if f["rol"] not in R.ROLES_RODAJE:
|
||
# un proyecto, un guion o una imagen de IA que estaban de paso: van donde iría cualquier
|
||
# archivo suelto de su clase, con el mes de esta carpeta
|
||
self.plan_fila(f, "contenedor", anio, mes)
|
||
continue
|
||
fuente = R.FUENTE_CARPETA.get(f["fuente"], None)
|
||
if fuente is None:
|
||
if f["rol"] in ("audio", "audio_grabado"):
|
||
fuente = "AUDIO"
|
||
elif f["rol"] in ("imagen", "foto_camara", "celular_foto"):
|
||
fuente = "FOTOS"
|
||
else:
|
||
fuente = "OTROS"
|
||
# lo que digan las carpetas intermedias: el año o el mes («Blackmagic (BRAW)/2023/…»), el
|
||
# nombre de la sesión («Grabaciones/Tomas consultorio/…») y si ya estaba apartado por repetido
|
||
anio_ruta, mes_ruta, etiqueta, repetido = None, None, etiqueta_base, False
|
||
resto = [] # subcarpetas con sentido debajo de la sesión: se conservan debajo de la cámara
|
||
segs = [] if os.path.normcase(os.path.normpath(f["dir"])) == aqui else os.path.relpath(f["dir"], e.path).split(os.sep)
|
||
for seg in segs:
|
||
if R.detectar_anio(seg):
|
||
anio_ruta = R.detectar_anio(seg)
|
||
elif R.detectar_mes(seg)[0]:
|
||
mes_ruta = R.detectar_mes(seg)[0]
|
||
elif R.norm(seg) == R.norm(R.DUPLICADOS):
|
||
repetido = True
|
||
elif not etiqueta and R.norm(seg) not in R.DICCIONARIO_SEMILLA and not R.es_carpeta_de_tarjeta(seg):
|
||
etiqueta = R.etiqueta_grabacion(seg)
|
||
elif not _solo_fuente(seg) and R.norm(seg) not in R.DICCIONARIO_SEMILLA:
|
||
# Medido el 14-oct-2026 en Vía Sumapaz: cada evento de «Fotos varias» guarda la misma foto
|
||
# en «Alta» y en «Baja», con el mismo nombre. Juntarlas en una sola carpeta hacía chocar
|
||
# una con otra: 753 fotos no se movieron. Lo que no dice cámara, tarjeta ni día se conserva.
|
||
resto.append(limpiar_nombre(seg))
|
||
fa, fm = int(f["fecha"][:4]), int(f["fecha"][5:7])
|
||
por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado»
|
||
if anio and mes:
|
||
# La carpeta dice un mes y el archivo puede decir otro. Medido el 16-sep-2026 sobre el
|
||
# material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes que
|
||
# anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre de 2025).
|
||
#
|
||
# Manda el archivo, no la carpeta: el nombre lo pone la cámara y la fecha de creación la
|
||
# pone el disco, mientras que el nombre de la carpeta lo escribió una persona con prisa.
|
||
# Así no hay que preguntarle a nadie cuál es el mes bueno. Y el año también es el del
|
||
# archivo: antes se deducía del año de la carpeta, y unos clips de diciembre de 2025
|
||
# metidos en «2025\03- Marzo» salían hacia diciembre de 2024.
|
||
ay, am, fconf = anio, mes, "alta"
|
||
if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
|
||
ay, am, fconf = fa, fm, "media"
|
||
desacuerdo[(fa, fm)] += 1
|
||
else:
|
||
ay, am = fa, fm
|
||
# la fecha de un original de cámara es la del rodaje aunque no esté en el nombre
|
||
fconf = "media" if por_nombre or _fecha_de_camara(f) else "baja"
|
||
# el año de una carpeta intermedia ("…/2023/…") manda sobre la fecha de modificación,
|
||
# pero no sobre una fecha escrita en el nombre del archivo
|
||
if anio_ruta and not por_nombre:
|
||
ay = anio_ruta
|
||
fconf = "media"
|
||
if anio and not por_nombre:
|
||
ay = anio
|
||
if mes_ruta and not (por_nombre and _discrepa_mes(f["fecha"], f["fecha_origen"], anio_ruta or anio or fa, mes_ruta)):
|
||
am = mes_ruta
|
||
fconf = "media"
|
||
hoy = datetime.date.today()
|
||
if (ay, am) > (hoy.year, hoy.month):
|
||
ay -= 1
|
||
grupos[(ay, am, self.dia_de(f["fecha"], ay, am), etiqueta, fuente, tuple(resto), repetido, fconf)].append((f["ruta"], f["bytes"]))
|
||
aviso_fecha = ""
|
||
# hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
|
||
# toda la carpeta sin marcar.
|
||
n_fechables = sum(1 for f in filas if not f["es_basura"] and not f["es_proxy"] and f["fecha_origen"] != "mtime")
|
||
if desacuerdo and sum(desacuerdo.values()) >= 3 and sum(desacuerdo.values()) >= 0.25 * max(1, n_fechables):
|
||
(da, dm), cuantos = desacuerdo.most_common(1)[0]
|
||
aviso_fecha = (f" · la carpeta decía {R.nombre_mes(mes)}, pero {cuantos} de sus archivos son de "
|
||
f"{R.nombre_mes(dm)} de {da}: van a su mes de verdad")
|
||
self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de "
|
||
f"sus archivos se grabaron en {R.nombre_mes(dm)} de {da}. Se archivan en el mes en que "
|
||
f"se grabaron, no en el que dice la carpeta.")
|
||
for (ay, am, dia, etiqueta, fuente, resto, repetido, fconf), archivos in grupos.items():
|
||
sub = os.path.join(*[x for x in (etiqueta, fuente, *resto, R.DUPLICADOS if repetido else "") if x])
|
||
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=dia, sub=sub,
|
||
nombre=limpiar_nombre(nombre))
|
||
# si todos ya están en esa carpeta, no hay nada que hacer
|
||
if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
|
||
continue
|
||
mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else ""))
|
||
if dia:
|
||
mot += " · y por día de rodaje"
|
||
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf,
|
||
archivos=archivos)
|
||
|
||
def plan_archivo(self, e, nivel, anio, mes, padre):
|
||
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
|
||
if fila:
|
||
self.plan_fila(fila, nivel, anio, mes)
|
||
|
||
def plan_fila(self, fila, nivel, anio, mes):
|
||
"""Un archivo suelto: a dónde va por lo que es."""
|
||
if fila["es_basura"] or fila["es_proxy"]:
|
||
return # los recoge plan_basura
|
||
if nivel == "pieza":
|
||
return
|
||
rol = fila["rol"]
|
||
cub = R.ROL_A_CUBETA.get(rol, "REVISAR")
|
||
conf = "media"
|
||
motivo = f"archivo suelto ({rol})"
|
||
fa, fm = int(fila["fecha"][:4]), int(fila["fecha"][5:7])
|
||
if anio and fila["fecha_origen"] == "mtime":
|
||
fa = anio
|
||
if mes:
|
||
# el archivo manda sobre la carpeta cuando su propia fecha es de fiar, igual que dentro de una
|
||
# carpeta de grabación: si no, un clip de diciembre traspapelado en la carpeta de marzo se
|
||
# quedaba archivado en marzo, que es justo lo que 2.2.0 dejó de hacer con las grabaciones.
|
||
if _discrepa_mes(fila["fecha"], fila["fecha_origen"], anio or fa, mes):
|
||
conf = "media"
|
||
motivo += (f" · la carpeta dice {R.nombre_mes(mes)} y el archivo se grabó en "
|
||
f"{R.nombre_mes(fm)}: manda el archivo")
|
||
else:
|
||
fm = mes
|
||
elif fila["fecha_origen"] == "mtime" and not _fecha_de_camara(fila):
|
||
conf = "baja"
|
||
motivo += " · mes por fecha de modificación"
|
||
hoy = datetime.date.today()
|
||
if (fa, fm) > (hoy.year, hoy.month):
|
||
fa -= 1
|
||
sub = ""
|
||
if cub == "GRABACION":
|
||
sub = R.FUENTE_CARPETA.get(fila["fuente"], "OTROS")
|
||
conf = "alta" if (anio and mes) else conf
|
||
if cub == "CELULAR":
|
||
if self.tipo == "contenido":
|
||
cub = "GRABACION"
|
||
sub = "CELULAR"
|
||
else:
|
||
sub = fila["fecha"][:7] if not (anio and mes) else ""
|
||
if cub == "PORTADAS" and nivel not in ("mes",):
|
||
cub = "REVISAR" if rol == "imagen" and nivel == "raiz" else cub
|
||
conf = "baja" if cub == "REVISAR" else conf
|
||
if cub == "EXPORTADOS":
|
||
conf = "media" if nivel == "mes" else "baja"
|
||
motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "")
|
||
if cub == "PROYECTOS":
|
||
# el programa manda sobre el rol: un .aep y un .psd sueltos en la misma carpeta van cada uno
|
||
# a la suya, que es justo lo que cuesta encontrar cuando están mezclados
|
||
sub = R.app_proyecto(fila["ext"]) or ("Diseño" if rol == "diseno" else "Edicion")
|
||
motivo = f"proyecto de {sub} suelto" if R.app_proyecto(fila["ext"]) else "proyecto de edición / diseño suelto"
|
||
conf = "alta"
|
||
if cub == "MUSICA" and rol == "audio":
|
||
conf = "baja"
|
||
if cub == "REVISAR":
|
||
destino = "_Revisar"
|
||
elif cub == "PROYECTOS":
|
||
destino = destino_plantilla(self.tipo, "PROYECTOS", app=sub)
|
||
else:
|
||
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="",
|
||
dia=self.dia_de(fila["fecha"], fa, fm) if cub in ("GRABACION", "CELULAR") else "")
|
||
self.suelto(fila, cub, destino, motivo, conf)
|
||
|
||
def plan_auditoria(self):
|
||
"""Lo que ya está en su carpeta de grabación, ¿está en el mes en que se grabó?
|
||
|
||
Las carpetas de la estructura no se vuelven a ordenar, así que lo que una versión anterior (o una
|
||
persona) dejó en el mes equivocado se quedaba ahí para siempre. Medido el 4-oct-2026 sobre el
|
||
material real: 160 clips de diciembre de 2025 (429 GB) vivían en la 00_Grabacion de marzo de 2025, y
|
||
había material de marzo en octubre, de junio en septiembre y de septiembre en noviembre. Aquí se
|
||
repasa solo lo que tiene la fecha en el nombre, que es lo que no admite discusión."""
|
||
pat = self.patron_grab
|
||
if not pat:
|
||
return
|
||
ia, im = pat.index("{anio}"), pat.index("{mes}")
|
||
roles = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video", "foto_camara")
|
||
por_mes = defaultdict(list) # (año, mes) de la carpeta -> [(archivo, lo que cuelga por debajo)]
|
||
for f in self.con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN (%s)"
|
||
% ",".join("?" * len(roles)), (self.raiz,) + roles).fetchall():
|
||
rel = os.path.relpath(f["dir"], self.raiz)
|
||
segs = [] if rel == "." else rel.split(os.sep)
|
||
if len(segs) < len(pat):
|
||
continue
|
||
for s, p in zip(segs, pat):
|
||
if not (R.detectar_anio(s) if p == "{anio}" else R.detectar_mes(s)[0] if p == "{mes}" else R.norm(s) == R.norm(p)):
|
||
break
|
||
else:
|
||
resto = segs[len(pat):]
|
||
tenia_dia = bool(resto and _RX_DIA.match(resto[0]))
|
||
if tenia_dia:
|
||
resto = resto[1:] # la carpeta del día se vuelve a calcular en destino
|
||
por_mes[(R.detectar_anio(segs[ia]), R.detectar_mes(segs[im])[0])].append((f, resto, tenia_dia))
|
||
grupos = defaultdict(list)
|
||
grupos_dia = defaultdict(list) # ya en su mes, pero sin carpeta de día o con la grabadora en «AUDIO»
|
||
for (anio_c, mes_c), fs in por_mes.items():
|
||
fuera = [(f, resto) for f, resto, _ in fs if _discrepa_mes(f["fecha"], f["fecha_origen"], anio_c, mes_c)]
|
||
if fuera:
|
||
# lo que solo tiene fecha de modificación pero es del mismo día que lo que se va (la Sony que
|
||
# rodó junto a la URSA) se va con ello: si no, el rodaje quedaría partido entre dos meses
|
||
dias = {f["fecha"] for f, _ in fuera}
|
||
fuera += [(f, resto) for f, resto, _ in fs
|
||
if f["fecha_origen"] == "mtime" and f["fecha"] in dias and _fecha_de_camara(f)]
|
||
for f, resto in fuera:
|
||
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
|
||
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am,
|
||
dia=self.dia_de(f["fecha"], ay, am), sub=os.sep.join(resto))
|
||
grupos[(f["dir"], destino, anio_c, mes_c, ay, am)].append((f["ruta"], f["bytes"], f["fecha_origen"] == "nombre"))
|
||
# Lo que sí está en su mes: desde la 3.6.0 las grabaciones van siempre dentro de su día de
|
||
# rodaje y cada grabadora en su carpeta (DJI_MIC, TASCAM, ZOOM), así que lo ordenado con
|
||
# versiones anteriores (sin carpeta de día, o con la grabadora en «AUDIO») se vuelve a mirar.
|
||
# Solo lo que tiene fecha de fiar (en el nombre, o un original de cámara, cuya fecha de
|
||
# modificación es la del rodaje): lo demás se queda donde está.
|
||
idos = {f["ruta"] for f, _ in fuera}
|
||
for f, resto, tenia_dia in fs:
|
||
if f["ruta"] in idos or (f["fecha_origen"] == "mtime" and not _fecha_de_camara(f)):
|
||
continue
|
||
nuevo = list(resto)
|
||
fu = R.FUENTE_CARPETA.get(f["fuente"])
|
||
if fu and fu != "AUDIO" and f["rol"] == "audio_grabado":
|
||
for i in range(len(nuevo)):
|
||
if R.norm(nuevo[i]) == "audio":
|
||
nuevo[i] = fu
|
||
if tenia_dia and nuevo == resto:
|
||
continue
|
||
destino = destino_plantilla(self.tipo, "GRABACION", anio=anio_c, mes=mes_c,
|
||
dia=self.dia_de(f["fecha"], anio_c, mes_c), sub=os.sep.join(nuevo))
|
||
if self.abs_(destino).lower() == os.path.normpath(f["dir"]).lower():
|
||
continue
|
||
grupos_dia[(f["dir"], destino, not tenia_dia, nuevo != resto)].append((f["ruta"], f["bytes"]))
|
||
for (origen, destino, sin_dia, grabadora), archivos in grupos_dia.items():
|
||
partes = (["sin la carpeta del día de rodaje"] if sin_dia else []) + (["la grabadora va en su propia carpeta, no en «AUDIO»"] if grabadora else [])
|
||
self.agregar(origen, self.abs_(destino), "GRABACION", "mover archivos",
|
||
"ya en su mes, pero " + " y ".join(partes), "media", archivos=archivos)
|
||
n = b = 0
|
||
for (origen, destino, anio_c, mes_c, ay, am), archivos in grupos.items():
|
||
n += len(archivos)
|
||
b += sum(a[1] for a in archivos)
|
||
self.agregar(origen, self.abs_(destino), "GRABACION", "mover archivos",
|
||
f"ya estaba ordenado, pero en {R.nombre_mes(mes_c)} de {anio_c}: se grabó en {R.nombre_mes(am)} de {ay}",
|
||
"alta" if all(a[2] for a in archivos) else "media",
|
||
archivos=[(a[0], a[1]) for a in archivos])
|
||
if n:
|
||
self.avisos.append(f"{_plural(n, 'clip ya ordenado estaba', 'clips ya ordenados estaban')} en un mes que no es el "
|
||
f"de su rodaje ({_gb(b)}). El plan los lleva al mes en que se grabaron, que es lo que dice "
|
||
f"el nombre que les puso la cámara.")
|
||
|
||
def plan_basura(self):
|
||
con = self.con
|
||
# «_Papelera» era el nombre de antes. Las carpetas ordenadas con versiones viejas tienen las dos, y
|
||
# lo apartado quedaba repartido entre una y otra: se junta todo en la de ahora.
|
||
vieja = os.path.join(self.raiz, R.PAPELERAS[1])
|
||
if os.path.isdir(vieja):
|
||
self.agregar(vieja, self.abs_(R.PAPELERA), "BASURA", "papelera",
|
||
f"la papelera con el nombre antiguo se junta con «{R.PAPELERA}»: era lo mismo en dos sitios", "alta")
|
||
# carpetas enteras de basura / proxy ya salieron en plan_subdir si estaban a la vista;
|
||
# aquí van las que están más adentro (dentro de piezas) y los archivos sueltos de basura
|
||
carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall()
|
||
cubiertas = []
|
||
# se mira subiendo por la ruta de cada uno, no recorriendo la lista entera: en Vía Sumapaz (13.899
|
||
# archivos de proxy o basura, 540 carpetas cubiertas) eran 5 millones de comparaciones y 13 s
|
||
cub_set = set()
|
||
origenes = {os.path.normpath(f["origen"]).lower() for f in self.filas}
|
||
for c in sorted(carpetas, key=lambda c: len(c["ruta"])):
|
||
if _bajo_alguna(os.path.dirname(c["ruta"]), cub_set):
|
||
continue
|
||
if c["ruta"].lower() in origenes:
|
||
cubiertas.append(c["ruta"])
|
||
cub_set.add(c["ruta"].lower())
|
||
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"])
|
||
cub_set.add(c["ruta"].lower())
|
||
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 _bajo_alguna(f["dir"], cub_set):
|
||
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)
|
||
# ==========================================================================
|
||
|
||
# carpeta de cliente (en minúsculas) -> cuántas veces ha movido la app algo de ella: el escaneo lo mira (un
|
||
# contador y no la hora: en Windows dos momentos seguidos pueden dar la misma)
|
||
_TOCADO = {}
|
||
|
||
|
||
def bitacora(entrada):
|
||
entrada["ts"] = time.time()
|
||
if entrada.get("op") in ("mover", "rmdir", "mkdir"):
|
||
for k in ("de", "a"):
|
||
r = _raiz_de(entrada[k]) if entrada.get(k) else None
|
||
if r:
|
||
_TOCADO[r.lower()] = _TOCADO.get(r.lower(), 0) + 1
|
||
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 _renombrar(origen, destino, intentos=4):
|
||
"""os.rename, esperando un momento si el archivo lo tiene abierto un instante otro programa: la lectura de
|
||
clips del escaneo profundo, el antivirus o la vista previa del Explorador. Windows no deja mover un archivo
|
||
abierto (error 32), y antes eso contaba como fallo aunque al segundo siguiente ya estuviera libre. Lo que
|
||
sigue abierto de verdad (un proyecto de Resolve) falla igual, tras menos de dos segundos."""
|
||
for i in range(intentos):
|
||
try:
|
||
os.rename(origen, destino)
|
||
return
|
||
except OSError as ex:
|
||
if getattr(ex, "winerror", None) not in (32, 33) or i == intentos - 1:
|
||
raise
|
||
time.sleep(0.3 * (i + 1))
|
||
|
||
|
||
# carpeta (en minúsculas) -> cómo está escrita en el disco; vale dos minutos, y se olvida al mover una carpeta
|
||
_MAYUS = {"t": 0.0, "d": {}}
|
||
|
||
|
||
def _olvidar_mayusculas():
|
||
_MAYUS["d"].clear()
|
||
_MAYUS["t"] = time.time()
|
||
|
||
|
||
def _como_en_disco(d):
|
||
"""La carpeta `d` escrita con las mayúsculas que tiene en el disco (si existe; si no, como viene). Windows no
|
||
distingue «Altas Print» de «ALTAS PRINT»: si el plan mueve a la primera y en el disco está la segunda, el
|
||
archivo cae en la que había, pero el inventario apuntaba la ruta del plan. Medido el 14-oct-2026 en Vía
|
||
Sumapaz: 1.039 fotos con la carpeta mal escrita en el inventario, y la vista de carpetas enseñaba «Altas
|
||
print», «Altas Print» y «ALTAS PRINT» donde en el disco hay una sola. Se pregunta al disco una vez por
|
||
carpeta, desde la carpeta de cliente hacia abajo."""
|
||
if time.time() - _MAYUS["t"] > 120:
|
||
_olvidar_mayusculas()
|
||
cache = _MAYUS["d"]
|
||
k = d.lower()
|
||
if k in cache:
|
||
return cache[k]
|
||
raiz = _raiz_de(d)
|
||
padre, nombre = os.path.split(d)
|
||
if not raiz or k == raiz.lower() or not nombre or padre == d:
|
||
real = raiz if raiz and k == raiz.lower() else d
|
||
else:
|
||
arriba = _como_en_disco(padre)
|
||
try:
|
||
escritos = {n.lower(): n for n in os.listdir(arriba)}
|
||
except OSError:
|
||
escritos = {}
|
||
real = os.path.join(arriba, escritos.get(nombre.lower(), nombre))
|
||
cache[k] = real
|
||
return real
|
||
|
||
|
||
def mover(origen, destino, lote, movidos):
|
||
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
|
||
origen = os.path.normpath(origen)
|
||
destino = os.path.normpath(destino)
|
||
if origen == destino:
|
||
return
|
||
destino = os.path.join(_como_en_disco(os.path.dirname(destino)), os.path.basename(destino))
|
||
if origen == destino:
|
||
return
|
||
if origen.lower() == destino.lower():
|
||
# solo cambian mayúsculas («10- octubre» -> «10- Octubre»). En Windows es el mismo nombre, así
|
||
# que isdir/exists del destino dicen que sí y el camino normal lo tomaría por una fusión de una
|
||
# carpeta consigo misma. Antes se devolvía sin hacer nada y el plan prometía un renombrado que
|
||
# nunca ocurría: os.rename sí sabe hacerlo en NTFS.
|
||
_renombrar(origen, destino)
|
||
_olvidar_mayusculas()
|
||
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
|
||
movidos.append((origen, destino))
|
||
return
|
||
es_dir = os.path.isdir(origen)
|
||
if es_dir and os.path.isdir(destino):
|
||
# fusión: cada hijo va por su cuenta. Si uno tropieza (ya existe con ese nombre, está abierto),
|
||
# los demás siguen; antes el primero que fallaba cortaba el bucle y la carpeta quedaba medio
|
||
# vaciada, con el aviso hablando de un solo archivo.
|
||
fallos = []
|
||
for nombre in os.listdir(origen):
|
||
try:
|
||
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
|
||
except OSError as ex:
|
||
fallos.append(f"{nombre}: {ex}")
|
||
try:
|
||
os.rmdir(origen)
|
||
bitacora({"lote": lote, "op": "rmdir", "de": origen})
|
||
except OSError:
|
||
pass
|
||
if fallos:
|
||
raise OSError(f"{len(fallos)} de «{os.path.basename(origen)}» se quedaron: " + "; ".join(fallos[:3])
|
||
+ (f"; y {len(fallos) - 3} más" if len(fallos) > 3 else ""))
|
||
return
|
||
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
||
if os.path.exists(destino):
|
||
# dentro de _Para borrar o Duplicados el nombre da igual: se numera. Fuera, NUNCA se renombra
|
||
# (Resolve/Premiere enlazan por nombre y los sidecar Sony van emparejados): se deja donde está y se avisa.
|
||
if _fuera_de_dup(destino):
|
||
raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo")
|
||
destino = nombre_libre(destino)
|
||
try:
|
||
_renombrar(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
|
||
if es_dir:
|
||
_olvidar_mayusculas() # lo que había debajo de la carpeta movida ya no está ahí
|
||
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
|
||
movidos.append((origen, destino))
|
||
|
||
|
||
# hechas/total cuentan filas del plan (es lo que miran «en marcha» y la barra de tareas); unidades cuenta
|
||
# archivo a archivo dentro de las filas de archivos sueltos, para que la barra avance suave en una fila de
|
||
# 400 clips; actual/archivo dicen qué se está moviendo; lote/fin, qué tanda fue y cuándo acabó.
|
||
APLICANDO = {"hechas": 0, "total": 0, "actual": "", "archivo": "", "unidades": 0, "unidades_total": 0,
|
||
"movidos": 0, "fallos": 0, "lote": "", "inicio": 0, "fin": 0}
|
||
|
||
|
||
def _motivo_fallo(ex):
|
||
"""Por qué no se pudo mover algo, en una palabra: para agrupar los fallos y decir qué hacer con cada clase."""
|
||
t = str(ex).lower()
|
||
w = getattr(ex, "winerror", None)
|
||
if isinstance(ex, FileExistsError) or "ya existe" in t:
|
||
return "existe"
|
||
if "en uso" in t or w in (32, 33):
|
||
return "en_uso"
|
||
if isinstance(ex, PermissionError) or w == 5 or "acceso denegado" in t:
|
||
return "permiso"
|
||
if isinstance(ex, FileNotFoundError) or "ya no está" in t or w in (2, 3):
|
||
return "no_esta"
|
||
if "incompleta" in t:
|
||
return "copia"
|
||
if w in (53, 59, 64, 67, 1231) or "red" in t:
|
||
return "red"
|
||
return "otro"
|
||
|
||
|
||
def aplicar(raiz, filas):
|
||
lote = time.strftime("%Y%m%d-%H%M%S")
|
||
movidos = []
|
||
errores = []
|
||
motivos = {} # motivo -> {"n", "ejemplos"}: los fallos agrupados, para explicarlos
|
||
hechas = 0
|
||
|
||
def fallo(ex, que):
|
||
m = motivos.setdefault(_motivo_fallo(ex), {"n": 0, "ejemplos": []})
|
||
m["n"] += 1
|
||
if len(m["ejemplos"]) < 8:
|
||
m["ejemplos"].append(que)
|
||
filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
|
||
unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas)
|
||
# el convertido que está junto a su original lo acompaña: si no, el original se iba a 00_Grabacion y su
|
||
# «_10bit» se quedaba en la carpeta vieja (el inventario aún no lo tenía si se convirtió después de escanear)
|
||
con = db()
|
||
convertidos = defaultdict(list)
|
||
for r in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('listo','verificado') AND salida IS NOT NULL"):
|
||
if os.path.dirname(r["salida"]).lower() == os.path.dirname(r["ruta"] or "").lower():
|
||
convertidos[os.path.normpath(r["ruta"])].append(os.path.normpath(r["salida"]))
|
||
con.close()
|
||
en_plan = {os.path.normpath(x).lower() for f in filas for x in (f.get("archivos") or ())}
|
||
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
|
||
fallos = 0
|
||
APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0,
|
||
unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0)
|
||
for f in filas:
|
||
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "", archivo="")
|
||
if f.get("archivos"):
|
||
# cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los
|
||
# ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía
|
||
# que había fallado uno solo.
|
||
especiales = f.get("destinos_archivo") or {}
|
||
fallos_fila = 0
|
||
try:
|
||
if not especiales:
|
||
os.makedirs(f["destino"], exist_ok=True)
|
||
except Exception as ex: # noqa
|
||
errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}")
|
||
continue
|
||
for a in f["archivos"]:
|
||
APLICANDO.update(unidades=APLICANDO["unidades"] + 1, archivo=os.path.basename(a))
|
||
if not os.path.exists(a):
|
||
continue
|
||
try:
|
||
antes = len(movidos)
|
||
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
|
||
llego = movidos[-1][1] if len(movidos) > antes else ""
|
||
for s in convertidos.get(os.path.normpath(a), ()):
|
||
if llego and s.lower() not in en_plan and not any(m in llego for m in marcas) and os.path.isfile(s):
|
||
mover(s, os.path.join(os.path.dirname(llego), os.path.basename(s)), lote, movidos)
|
||
except Exception as ex: # noqa
|
||
fallos_fila += 1
|
||
fallo(ex, os.path.relpath(a, raiz))
|
||
if fallos_fila <= 5:
|
||
errores.append(f"{os.path.basename(a)}: {ex}")
|
||
APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila)
|
||
fallos += fallos_fila
|
||
if fallos_fila > 5:
|
||
errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover")
|
||
if not fallos_fila:
|
||
hechas += 1
|
||
else:
|
||
try:
|
||
if os.path.exists(f["origen"]):
|
||
mover(f["origen"], f["destino"], lote, movidos)
|
||
hechas += 1
|
||
else:
|
||
fallos += 1
|
||
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
|
||
fallo(FileNotFoundError("ya no está"), f.get("origen_rel") or "")
|
||
except Exception as ex: # noqa
|
||
fallos += 1
|
||
errores.append(f"{f.get('origen_rel')}: {ex}")
|
||
fallo(ex, f.get("origen_rel") or "")
|
||
APLICANDO.update(unidades=APLICANDO["unidades"] + 1, movidos=len(movidos), fallos=fallos)
|
||
# terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
|
||
APLICANDO.update(hechas=len(filas), actual="", archivo="", unidades=unidades_total, movidos=len(movidos),
|
||
fallos=fallos, fin=time.time())
|
||
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
|
||
for f in filas:
|
||
base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"])
|
||
if os.path.normpath(base).lower() != os.path.normpath(raiz).lower():
|
||
quitar_vacias(base, raiz)
|
||
actualizar_inventario(movidos)
|
||
# qué pasos del plan entraron (limpiar, fechas, resto, banco): Inicio les pone su ✓ con esto
|
||
pasos = dict(Counter(f.get("paso") or "resto" for f in filas))
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores, "pasos": pasos,
|
||
"motivos": motivos})
|
||
return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores, "motivos": motivos,
|
||
"fallos": sum(m["n"] for m in motivos.values())}
|
||
|
||
|
||
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."""
|
||
if not movidos:
|
||
return
|
||
con = db()
|
||
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
|
||
# Antes de tomar la base, qué carpetas tocadas siguen existiendo: eso hay que preguntarlo al disco, pero
|
||
# todo de una vez y en paralelo. Medido el 14-oct-2026 en Vía Sumapaz, 2.493 movimientos: se preguntaba al
|
||
# disco de red dos veces por movimiento, a 41 ms cada vez con el NAS ocupado, y todo con la base tomada;
|
||
# más de diez minutos en los que «Buscar clips» se rendía con «database is locked». Lo que hay dentro de
|
||
# cada carpeta se mira en el inventario, por índice, movimiento a movimiento (no una vez al principio: una
|
||
# carpeta puede aparecer a mitad de la tanda, renombrada por un movimiento anterior).
|
||
tocadas = _tocadas(_rutas_finales(movidos))
|
||
existe = _existen({d for d, _ in tocadas})
|
||
cola = _cola_tras_mover(con, movidos, marcas)
|
||
with con:
|
||
for id_, c in cola:
|
||
con.execute(f"UPDATE trabajos SET {', '.join(k + ' = ?' for k in c)} WHERE id = ?", (*c.values(), id_))
|
||
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))
|
||
lo, hi = _rango(de + os.sep)
|
||
for r in con.execute("SELECT ruta FROM archivos WHERE ruta >= ? AND ruta < ?", (lo, hi)).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 >= ? AND ruta < ?)", (de, lo, hi)).fetchall()
|
||
con.execute("DELETE FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi))
|
||
if viejas:
|
||
for v in viejas:
|
||
nueva = a if v["ruta"] == de else a + v["ruta"][len(de):]
|
||
fila = dict(v)
|
||
fila["ruta"] = nueva
|
||
fila["nombre"] = os.path.basename(nueva)
|
||
if en_papelera:
|
||
fila["es_basura"] = 1
|
||
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
|
||
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
|
||
fila["es_proxy"], fila["es_basura"]))
|
||
_recontar_carpetas(con, tocadas, existe)
|
||
con.close()
|
||
# el plan que hubiera en memoria ya no describe la carpeta: se olvida y se vuelve a pensar solo
|
||
for k in {r for de, a in movidos for r in (_raiz_de(de), _raiz_de(a)) if r}:
|
||
olvidar_plan(k)
|
||
|
||
|
||
# Qué sigue la cola cuando la app mueve algo. El clip, en todo lo que aún lo usa (el «descartado» también:
|
||
# así no se vuelve a probar a la misma calidad tras moverlo). El convertido, en lo que ya salió. «corriendo»
|
||
# no: ese clip lo tiene abierto ffmpeg, y Windows no deja moverlo. Un «reemplazado» guarda en su ruta dónde
|
||
# estaba el original, para encontrarlo en _Para borrar\originales: esa no se toca.
|
||
_SIGUEN_CLIP = ("pendiente", "error", "cancelado", "listo", "verificado", "descartado")
|
||
_SIGUEN_SALIDA = ("listo", "verificado", "reemplazado")
|
||
|
||
|
||
def _cola_tras_mover(con, movidos, marcas):
|
||
"""La cola de conversión apunta a cada clip por su ruta. Cuando el plan (o Deshacer, o apartar) lo mueve, el
|
||
trabajo se va con él: el que esperaba se convierte en el sitio nuevo y su convertido sale allí, y el ya
|
||
convertido se reemplaza donde está ahora su original. Medido el 14-oct-2026 en Línea Gene: el plan llevó
|
||
los clips de «Material Sony» a 00_Grabacion y 138 conversiones en espera fallaron buscando cada clip donde
|
||
ya no estaba; los 11 verificados no se podían reemplazar, y «Buscar clips» volvía a ofrecer esos mismos
|
||
originales como si nunca se hubieran convertido. Lo que va a la papelera de la app no se sigue: un clip
|
||
apartado no se convierte. Devuelve [(id, {columna: valor})]."""
|
||
ts = [dict(r) for r in con.execute("SELECT id, ruta, salida, estado, raiz, formato FROM trabajos WHERE estado != 'corriendo'")]
|
||
if not ts:
|
||
return []
|
||
antes = {t["id"]: (t["ruta"], t["salida"]) for t in ts}
|
||
apartes = list(marcas) + [os.sep + R.DUPLICADOS + os.sep] # ni lo que se aparta como repetido
|
||
for de, a in movidos:
|
||
if any(m in a for m in apartes):
|
||
continue
|
||
pre = de + os.sep
|
||
for t in ts:
|
||
for k, siguen in (("ruta", _SIGUEN_CLIP), ("salida", _SIGUEN_SALIDA)):
|
||
v = t[k] or ""
|
||
if t["estado"] in siguen and (v == de or v.startswith(pre)):
|
||
t[k] = a + v[len(de):]
|
||
cambios = []
|
||
for t in ts:
|
||
ruta0, salida0 = antes[t["id"]]
|
||
if (t["ruta"], t["salida"]) == (ruta0, salida0):
|
||
continue
|
||
c = {"ruta": t["ruta"], "salida": t["salida"]}
|
||
if t["ruta"] != ruta0:
|
||
c["raiz"] = _raiz_de(t["ruta"]) or t["raiz"]
|
||
if t["estado"] in ("pendiente", "error", "cancelado"):
|
||
# aún no hay convertido: saldrá junto al clip, en su sitio nuevo
|
||
c["salida"] = salida_para(t["ruta"], c["raiz"], t["formato"] or "h265_10bit")
|
||
cambios.append((t["id"], c))
|
||
return cambios
|
||
|
||
|
||
def reubicar_trabajos(raiz=None, ids=None):
|
||
"""Vuelve a enlazar con su clip los trabajos de la cola que apuntan a donde el inventario ya no lo tiene: lo
|
||
movió la 3.7 o una anterior sin avisar a la cola, o alguien con el Explorador y luego se volvió a escanear.
|
||
Se busca el mismo nombre y el mismo peso en el inventario de la misma carpeta de cliente, fuera de la
|
||
papelera, y solo si sale uno. El que había fallado por eso vuelve a la cola, con su convertido junto al clip
|
||
en el sitio nuevo; el que ya se encoló de nuevo allí se quita (sobraba). Igual con el convertido de los ya
|
||
hechos, si se movió. Devuelve cuántos se enlazaron."""
|
||
con = db()
|
||
try:
|
||
escaneadas = {(r["raiz"] or "").lower() for r in con.execute("SELECT raiz FROM escaneos")}
|
||
filtro, args = "", []
|
||
if raiz:
|
||
filtro += " AND lower(t.raiz) = ?"
|
||
args.append(os.path.normpath(raiz).lower())
|
||
if ids:
|
||
filtro += f" AND t.id IN ({','.join('?' * len(ids))})"
|
||
args += [int(i) for i in ids]
|
||
estados = ",".join(f"'{e}'" for e in _SIGUEN_CLIP)
|
||
sin_clip = [dict(r) for r in con.execute(
|
||
"SELECT t.* FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||
f"WHERE a.ruta IS NULL AND t.estado IN ({estados}){filtro}", args)]
|
||
sin_salida = [dict(r) for r in con.execute(
|
||
"SELECT t.* FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.salida "
|
||
f"WHERE a.ruta IS NULL AND t.salida IS NOT NULL AND t.bytes_out > 0 AND t.estado IN ('listo','verificado','reemplazado'){filtro}", args)]
|
||
# sin inventario de esa carpeta (no escaneada, o de otro equipo) que no esté no dice nada
|
||
sin_clip = [t for t in sin_clip if (t["raiz"] or "").lower() in escaneadas]
|
||
sin_salida = [t for t in sin_salida if (t["raiz"] or "").lower() in escaneadas]
|
||
if not sin_clip and not sin_salida:
|
||
return 0
|
||
nombres = sorted({os.path.basename(t["ruta"] or "") for t in sin_clip} | {os.path.basename(t["salida"] or "") for t in sin_salida})
|
||
donde = defaultdict(list)
|
||
for i in range(0, len(nombres), 400):
|
||
parte = nombres[i:i + 400]
|
||
for r in con.execute(f"SELECT ruta, raiz, nombre, bytes FROM archivos WHERE es_basura = 0 AND nombre IN ({','.join('?' * len(parte))})", parte):
|
||
if os.sep + R.DUPLICADOS + os.sep not in r["ruta"]:
|
||
donde[((r["raiz"] or "").lower(), r["nombre"], r["bytes"])].append(r["ruta"])
|
||
ocupadas = {(r["ruta"], r["formato"] or "h265_10bit"): (r["id"], r["estado"]) for r in con.execute(
|
||
"SELECT id, ruta, formato, estado FROM trabajos WHERE estado IN ('pendiente','corriendo','listo','verificado','reemplazado')")}
|
||
n = 0
|
||
with con:
|
||
for t in sin_clip:
|
||
sitio = donde.get(((t["raiz"] or "").lower(), os.path.basename(t["ruta"] or ""), t["bytes_in"]))
|
||
if not sitio or len(sitio) != 1:
|
||
continue
|
||
nueva, fmt = sitio[0], t["formato"] or "h265_10bit"
|
||
otro = ocupadas.get((nueva, fmt))
|
||
if otro and t["estado"] in ("pendiente", "error", "cancelado"):
|
||
con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],))
|
||
continue
|
||
if otro and t["estado"] in ("listo", "verificado"):
|
||
# Ya estaba convertido y la 3.7, que no lo reconocía en su sitio nuevo, lo dejó encolar otra vez.
|
||
# Medido el 14-oct-2026 en Línea Gene: los 11 verificados se volvieron a encolar con los 136 que
|
||
# faltaban, y uno ya se había convertido dos veces. Si el nuevo aún espera, sobra él; si ya se
|
||
# hizo, sobra este, el de la carpeta vieja (lo aparta «Limpiar terminados»).
|
||
if otro[1] == "pendiente":
|
||
con.execute("DELETE FROM trabajos WHERE id = ?", (otro[0],))
|
||
else:
|
||
con.execute("UPDATE trabajos SET estado = 'descartado', log = ? WHERE id = ?",
|
||
(f"el clip se volvió a convertir en su sitio nuevo (#{otro[0]}): este convertido, en la carpeta "
|
||
"vieja, sobra. «Limpiar terminados» lo aparta a _Para borrar", t["id"]))
|
||
n += 1
|
||
continue
|
||
elif otro:
|
||
continue
|
||
c = {"ruta": nueva}
|
||
if t["estado"] in ("pendiente", "error", "cancelado"):
|
||
c["salida"] = salida_para(nueva, t["raiz"], fmt)
|
||
if t["estado"] == "error":
|
||
c.update(estado="pendiente", pct=0)
|
||
if t["estado"] in ("pendiente", "error"):
|
||
c["log"] = f"el clip se había movido; ahora está en «{os.path.relpath(os.path.dirname(nueva), t['raiz'])}» y se convierte allí"
|
||
con.execute(f"UPDATE trabajos SET {', '.join(k + ' = ?' for k in c)} WHERE id = ?", (*c.values(), t["id"]))
|
||
if t["estado"] in ("pendiente", "error", "listo", "verificado"):
|
||
ocupadas[(nueva, fmt)] = (t["id"], "pendiente" if t["estado"] == "error" else t["estado"])
|
||
n += 1
|
||
for t in sin_salida:
|
||
sitio = donde.get(((t["raiz"] or "").lower(), os.path.basename(t["salida"] or ""), t["bytes_out"]))
|
||
if sitio and len(sitio) == 1:
|
||
con.execute("UPDATE trabajos SET salida = ? WHERE id = ?", (sitio[0], t["id"]))
|
||
n += 1
|
||
return n
|
||
finally:
|
||
con.close()
|
||
|
||
|
||
def _rango(prefijo):
|
||
"""(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta».
|
||
«LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento."""
|
||
return prefijo, prefijo[:-1] + chr(ord(prefijo[-1]) + 1)
|
||
|
||
|
||
def _existen(rutas):
|
||
"""{carpeta: si existe}, preguntando al disco en paralelo: por la red cada pregunta tarda decenas de ms."""
|
||
rutas = list(rutas)
|
||
if not rutas:
|
||
return {}
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=24) as ex:
|
||
return dict(zip(rutas, ex.map(os.path.isdir, rutas)))
|
||
|
||
|
||
def _son_archivos(rutas):
|
||
"""{ruta: si es un archivo}, preguntando al disco en paralelo."""
|
||
rutas = list(rutas)
|
||
if not rutas:
|
||
return {}
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=24) as ex:
|
||
return dict(zip(rutas, ex.map(os.path.isfile, rutas)))
|
||
|
||
|
||
def _rutas_finales(movidos):
|
||
"""Los movimientos de una tanda con cada ruta escrita como quedó al final: uno posterior pudo mover o renombrar
|
||
la carpeta en la que estaba (el plan mete archivos en «10- octubre» y después la renombra a «10- Octubre»).
|
||
Para recontar las carpetas hay que mirarlas al final: Windows da por existente «10- octubre» aunque ya se
|
||
llame «10- Octubre», y el inventario acababa con las dos (y «Colores» pintaba dos veces la misma)."""
|
||
despues = {} # lo que un movimiento posterior se llevó -> dónde acabó
|
||
|
||
def final(p):
|
||
q, resto = p, ""
|
||
while True:
|
||
if q in despues:
|
||
return despues[q] + resto
|
||
padre = os.path.dirname(q)
|
||
if padre == q:
|
||
return p
|
||
resto = q[len(padre):] + resto
|
||
q = padre
|
||
out = []
|
||
for de, a in reversed(movidos):
|
||
fa = final(a)
|
||
out.append((os.path.join(final(os.path.dirname(de)), os.path.basename(de)), fa))
|
||
despues[de] = fa
|
||
out.reverse()
|
||
return out
|
||
|
||
|
||
def _tocadas(movidos):
|
||
"""Las carpetas cuyo recuento cambia con estos movimientos: las de origen y destino y todas las de encima. El
|
||
destino mismo entra siempre: si era un archivo, el disco dirá que no es una carpeta y no se apunta nada."""
|
||
tocadas = set()
|
||
for de, a in movidos:
|
||
for ruta in (os.path.dirname(de), os.path.dirname(a), a):
|
||
if not ruta:
|
||
continue
|
||
raiz = _raiz_de(ruta)
|
||
if not raiz:
|
||
continue
|
||
d = os.path.normpath(ruta)
|
||
while d.lower().startswith(raiz.lower()) and d.lower() != raiz.lower():
|
||
tocadas.add((d, raiz))
|
||
padre = os.path.dirname(d)
|
||
if padre == d:
|
||
break
|
||
d = padre
|
||
return tocadas
|
||
|
||
|
||
def _recontar_carpetas(con, tocadas, existe):
|
||
"""Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no
|
||
existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los
|
||
colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del
|
||
escaneo. Si existen o no ya se preguntó al disco antes (`existe`), fuera de la base."""
|
||
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
|
||
for d, raiz in tocadas:
|
||
if not existe.get(d):
|
||
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
|
||
continue
|
||
lo, hi = _rango(d + os.sep)
|
||
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR (dir >= ? AND dir < ?)",
|
||
(d, lo, hi)).fetchone()
|
||
previa = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (d,)).fetchone()
|
||
en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0
|
||
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||
(d, raiz, os.path.basename(d), fila["n"], fila["b"],
|
||
previa["anio_moda"] if previa else None, previa["mes_moda"] if previa else None,
|
||
previa["frac_moda"] if previa else 0, previa["dias_rango"] if previa else 0,
|
||
previa["es_proxy"] if previa else (1 if R.es_proxy_dir(os.path.basename(d)) else 0),
|
||
1 if en_papelera else (previa["es_basura"] if previa else 0)))
|
||
|
||
|
||
def _raiz_de(ruta):
|
||
"""La carpeta de cliente registrada a la que pertenece una ruta, o None."""
|
||
r = os.path.normpath(ruta).lower()
|
||
for c in CONFIG.get("raices", []):
|
||
k = os.path.normpath(c["ruta"]).lower()
|
||
if r == k or r.startswith(k + os.sep):
|
||
return os.path.normpath(c["ruta"])
|
||
return None
|
||
|
||
|
||
def quitar_vacias(ruta, tope):
|
||
"""borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)"""
|
||
ruta = os.path.normpath(ruta)
|
||
tope = os.path.normpath(tope)
|
||
while ruta.lower() != tope.lower() and ruta.lower().startswith(tope.lower()):
|
||
if R.norm(os.path.basename(ruta)) == R.norm(R.ENTRADA):
|
||
break # la bandeja de entrada se queda aunque esté vacía
|
||
try:
|
||
if os.path.isdir(ruta) and not os.listdir(ruta):
|
||
os.rmdir(ruta)
|
||
else:
|
||
break
|
||
except OSError:
|
||
break
|
||
ruta = os.path.dirname(ruta)
|
||
|
||
|
||
def _sin_archivos(ruta):
|
||
"""True si debajo de `ruta` no hay un solo archivo ni ningún enlace: nada que perder al borrarla.
|
||
|
||
Un enlace o una unión de Windows cuentan como contenido aunque no sean archivos: apuntan a otro
|
||
árbol y borrarlos sí perdería algo."""
|
||
try:
|
||
with os.scandir(ruta) as it:
|
||
for e in it:
|
||
if _es_enlace(e) or not e.is_dir(follow_symlinks=False):
|
||
return False
|
||
if not _sin_archivos(e.path):
|
||
return False
|
||
except OSError:
|
||
return False # no se puede leer: se da por ocupada
|
||
return True
|
||
|
||
|
||
def _pieza_nueva(ruta, desde):
|
||
"""True si la carpeta es una pieza creada después de `desde`, o el mes o el año que la contienen."""
|
||
try:
|
||
st = os.stat(ruta)
|
||
except OSError:
|
||
return False
|
||
if (getattr(st, "st_birthtime", None) or st.st_ctime) <= desde:
|
||
return False
|
||
nombre = os.path.basename(ruta)
|
||
if R.detectar_pieza(nombre):
|
||
return True
|
||
if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]:
|
||
try:
|
||
return any(_pieza_nueva(os.path.join(ruta, x), desde) for x in os.listdir(ruta))
|
||
except OSError:
|
||
return False
|
||
return False
|
||
|
||
|
||
def carpetas_vacias(raiz):
|
||
"""Carpetas sin un solo archivo dentro, en todo el árbol.
|
||
|
||
Ordenar deja huecos: repartida «Grabacion» por mes y por cámara, la carpeta original se queda sin
|
||
nada. `quitar_vacias` solo limpia el camino de lo que se acaba de mover, así que lo que ya estaba
|
||
vacío antes se queda ahí para siempre.
|
||
|
||
Los candidatos salen del inventario (instantáneo: una carpeta está vacía si ninguna ruta de
|
||
`archivos` cuelga de ella) y cada uno se comprueba en el disco antes de enseñarlo, porque el
|
||
inventario puede tener días. Solo se devuelve la de más arriba de cada rama: si «2026\03- Marzo»
|
||
está vacía entera, no hace falta enumerar sus seis subcarpetas."""
|
||
raiz = _clave(raiz)
|
||
con = db()
|
||
carpetas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
|
||
con_archivos = set()
|
||
for r in con.execute("SELECT DISTINCT dir FROM archivos WHERE raiz = ?", (raiz,)).fetchall():
|
||
d = os.path.normpath(r["dir"])
|
||
while d and d.lower().startswith(raiz.lower()): # el directorio y todos sus padres tienen algo
|
||
if d.lower() in con_archivos:
|
||
break
|
||
con_archivos.add(d.lower())
|
||
padre = os.path.dirname(d)
|
||
if padre == d:
|
||
break
|
||
d = padre
|
||
con.close()
|
||
vacias = sorted({os.path.normpath(c) for c in carpetas
|
||
if os.path.normpath(c).lower() not in con_archivos
|
||
and os.path.normpath(c).lower() != raiz.lower()}, key=lambda x: (len(x), x.lower()))
|
||
out, tomadas = [], []
|
||
reciente = time.time() - 30 * 86400
|
||
for c in vacias:
|
||
if any(c.lower().startswith(t + os.sep) for t in tomadas):
|
||
continue # ya cuelga de otra que se va entera
|
||
if c.lower() == os.path.join(raiz, R.ENTRADA).lower():
|
||
continue # la bandeja de entrada está vacía a propósito
|
||
if not os.path.isdir(c) or not _sin_archivos(c):
|
||
continue # ya no existe, o alguien dejó algo dentro
|
||
# una pieza recién creada tiene sus subcarpetas vacías porque aún no se ha trabajado en ella: no
|
||
# es un hueco que dejó el orden. Lo mismo el mes o el año que se crearon para ella.
|
||
if _pieza_nueva(c, reciente):
|
||
tomadas.append(c.lower())
|
||
continue
|
||
dentro = sum(len(d) for _, d, _ in os.walk(c))
|
||
out.append({"ruta": c, "rel": os.path.relpath(c, raiz), "subcarpetas": dentro})
|
||
tomadas.append(c.lower())
|
||
return {"raiz": raiz, "carpetas": out, "n": len(out)}
|
||
|
||
|
||
def borrar_vacias(raiz, rutas):
|
||
"""Borra carpetas vacías. Es lo único que la app borra, y solo porque no hay nada dentro que
|
||
perder: cada carpeta queda apuntada en la bitácora y «Deshacer» las vuelve a crear."""
|
||
lote = "vacias-" + time.strftime("%Y%m%d-%H%M%S")
|
||
raiz = _clave(raiz)
|
||
borradas, errores = 0, []
|
||
for r in sorted({os.path.normpath(x) for x in rutas}, key=lambda x: -len(x)):
|
||
if not dentro_de(raiz, r) or r.lower() == raiz.lower():
|
||
errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca")
|
||
continue
|
||
if not os.path.isdir(r):
|
||
continue # ya no está: nada que hacer
|
||
if not _sin_archivos(r):
|
||
errores.append(f"{os.path.basename(r)}: ya no está vacía, se deja como está")
|
||
continue
|
||
try:
|
||
for base, _, _ in sorted(os.walk(r), key=lambda x: -len(x[0])):
|
||
os.rmdir(base)
|
||
bitacora({"lote": lote, "op": "rmdir", "de": base})
|
||
borradas += 1
|
||
except OSError as ex:
|
||
errores.append(f"{os.path.basename(r)}: {ex}")
|
||
if borradas:
|
||
con = db()
|
||
with con:
|
||
for e in leer_bitacora(100000):
|
||
if e.get("lote") == lote and e.get("op") == "rmdir":
|
||
con.execute("DELETE FROM carpetas WHERE ruta = ?", (e["de"],))
|
||
con.close()
|
||
if borradas:
|
||
olvidar_plan(raiz)
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": borradas, "movimientos": borradas, "errores": errores})
|
||
return {"lote": lote, "borradas": borradas, "errores": errores}
|
||
|
||
|
||
def deshacer(lote):
|
||
todas = leer_bitacora(100000)
|
||
entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir", "mkdir")]
|
||
fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {})
|
||
raiz = fin.get("raiz") or ""
|
||
deshechos, errores, movidos, recreadas, quitadas = 0, [], [], [], []
|
||
for e in reversed(entradas):
|
||
try:
|
||
if e["op"] == "mkdir":
|
||
# una carpeta que creó la app (una pieza nueva): se quita solo si sigue vacía
|
||
if os.path.isdir(e["de"]):
|
||
if os.listdir(e["de"]):
|
||
errores.append(f"{os.path.basename(e['de'])}: ya tiene cosas dentro; se deja")
|
||
else:
|
||
os.rmdir(e["de"])
|
||
quitadas.append(e["de"])
|
||
deshechos += 1
|
||
elif e["op"] == "rmdir":
|
||
os.makedirs(e["de"], exist_ok=True)
|
||
if lote.startswith("vacias-"):
|
||
# aquí la carpeta ERA el cambio, no el rastro de uno: se cuenta y vuelve al inventario
|
||
recreadas.append(e["de"])
|
||
deshechos += 1
|
||
else:
|
||
if not os.path.exists(e["a"]):
|
||
errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve")
|
||
elif e["a"].lower() == e["de"].lower():
|
||
os.rename(e["a"], e["de"]) # solo cambiaban las mayúsculas: exists() diría que está ocupado
|
||
movidos.append((e["a"], e["de"]))
|
||
deshechos += 1
|
||
elif os.path.exists(e["de"]):
|
||
errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve")
|
||
else:
|
||
os.makedirs(os.path.dirname(e["de"]), exist_ok=True)
|
||
try:
|
||
os.rename(e["a"], e["de"])
|
||
except OSError:
|
||
shutil.move(e["a"], e["de"]) # el respaldo copió entre discos: se vuelve de la misma forma
|
||
movidos.append((e["a"], e["de"]))
|
||
deshechos += 1
|
||
if raiz:
|
||
quitar_vacias(os.path.dirname(e["a"]), raiz)
|
||
except OSError as ex:
|
||
errores.append(f"{e.get('a')}: {ex}")
|
||
actualizar_inventario(movidos)
|
||
if (recreadas or quitadas) and raiz:
|
||
con = db()
|
||
with con:
|
||
for d in recreadas:
|
||
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)",
|
||
(d, raiz, os.path.basename(d)))
|
||
for d in quitadas:
|
||
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
|
||
con.close()
|
||
bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores})
|
||
return {"deshechos": deshechos, "errores": errores}
|
||
|
||
|
||
# ==========================================================================
|
||
# Vocabulario (lo que la app "ve" que usamos como nombres)
|
||
# ==========================================================================
|
||
|
||
def vocabulario(raiz):
|
||
"""Las carpetas sobre las que hay algo que decidir, con lo que la app cree que es cada una.
|
||
|
||
Recorre el inventario igual que el plan recorre el disco y se queda solo con lo que el plan llega a
|
||
ver: no entra en las carpetas de la propia app (_Para borrar, 00_Grabacion…), ni dentro de las piezas,
|
||
ni en lo que ya sabe ordenar solo (una carpeta llena de clips de cámara, un cajón con meses dentro).
|
||
|
||
Hasta la 3.1.1 salían todas las carpetas del inventario, y eso preguntaba cosas que no cambiaban nada
|
||
o que no eran del cliente. Medido el 4-oct-2026 sobre el material real: de 6 preguntas, 6 sobraban
|
||
—«basura» era la _Para borrar\\basura de la app, «Orden» sus propias etiquetas dentro de 00_Grabacion,
|
||
y el resto carpetas que solo existían ya dentro de la papelera—, y contestarlas enseñó al diccionario
|
||
que «orden», «videos» y «basura» eran grabaciones."""
|
||
raiz = os.path.normpath(raiz)
|
||
tipo = (raiz_cfg(raiz) or {}).get("tipo", "contenido")
|
||
dicc = DICC["carpetas"]
|
||
con = db()
|
||
est = Estructura(con, raiz, dicc)
|
||
resumen = _resumen_carpetas(con, raiz)
|
||
con.close()
|
||
primeros, en_mes = _propias(tipo)
|
||
pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"])
|
||
por_mes = {c for c, p in pl.items() if "{mes}" in p}
|
||
abre_cajones = "{mes}" in (pl.get("PIEZA") or "")
|
||
tokens = {}
|
||
|
||
def apunta(ruta, titulo, cub, motivo, nota=""):
|
||
tok = R.norm(titulo)
|
||
if not tok or len(tok) > 60:
|
||
return
|
||
c = est.carp.get(os.path.normcase(ruta))
|
||
t = tokens.get(tok)
|
||
if t is None:
|
||
t = tokens[tok] = {"token": tok, "ejemplo": os.path.basename(ruta), "n": 0, "archivos": 0, "bytes": 0,
|
||
"rutas": [], "dentro_pieza": False, "cubeta": cub or "", "motivo": motivo, "nota": nota,
|
||
"aprendido": tok in dicc and tok not in R.DICCIONARIO_SEMILLA}
|
||
t["n"] += 1
|
||
t["archivos"] += (c["n"] if c else 0) or 0
|
||
t["bytes"] += (c["bytes"] if c else 0) or 0
|
||
if len(t["rutas"]) < 4:
|
||
t["rutas"].append(os.path.relpath(ruta, raiz))
|
||
|
||
def baja(ruta, nivel, anio):
|
||
for h in sorted(est.hijos(ruta), key=str.lower):
|
||
nombre = os.path.basename(h)
|
||
if nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA):
|
||
baja(h, "contenedor", None) # la bandeja de entrada: el plan la abre siempre
|
||
continue
|
||
if _es_propia(nombre, nivel, primeros, en_mes) or R.es_basura_dir(nombre) or R.es_proxy_dir(nombre):
|
||
continue
|
||
if not est.utiles(h):
|
||
continue # vacía: nada que decidir (se ofrece en «Carpetas vacías»)
|
||
a = R.detectar_anio(nombre)
|
||
if a and nivel in ("raiz", "contenedor"):
|
||
baja(h, "anio" if nivel == "raiz" else "contenedor", a)
|
||
continue
|
||
if nivel == "anio" and R.detectar_mes(nombre)[0]:
|
||
baja(h, "mes", anio)
|
||
continue
|
||
if nivel in ("raiz", "contenedor") and R.detectar_mes(re.sub(r"20\d\d", " ", nombre).strip(" -_"))[0]:
|
||
baja(h, "mes", anio)
|
||
continue
|
||
if nivel == "mes":
|
||
p = R.detectar_pieza(nombre)
|
||
if p:
|
||
cub_t, mot = R.cubeta_carpeta(p[1], dicc)
|
||
if cub_t == "GRABACION" and est.es_rodaje(h, por_nombre=_de_fabrica(p[1], "GRABACION")):
|
||
continue # la sesión del mes: el plan la reparte sola
|
||
if cub_t in ("GRABACION", "CONTENEDOR") and est.fechas_dentro(h):
|
||
apunta(h, p[1], "CONTENEDOR", mot)
|
||
baja(h, "contenedor", None)
|
||
elif cub_t != "PIEZA" and est.fechas_dentro(h):
|
||
apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas")
|
||
continue # una pieza: no se toca por dentro
|
||
cub, mot = R.cubeta_carpeta(nombre, dicc)
|
||
if cub in ("IGNORAR", "BANCO"):
|
||
apunta(h, nombre, cub, mot)
|
||
continue
|
||
if cub == "CONTENEDOR":
|
||
apunta(h, nombre, cub, mot)
|
||
m_anio = re.search(r"(20\d\d)", nombre)
|
||
baja(h, "contenedor", int(m_anio.group(1)) if m_anio else (None if est.fechas_dentro(h) else anio))
|
||
continue
|
||
montada = False
|
||
if cub == "GRABACION":
|
||
if est.es_rodaje(h, por_nombre=_de_fabrica(nombre, "GRABACION")):
|
||
apunta(h, nombre, cub, mot)
|
||
continue
|
||
cub, montada = None, True
|
||
if abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in por_mes) and est.fechas_dentro(h) \
|
||
and (montada or cub is not None or not est.es_rodaje(h)):
|
||
baja(h, "contenedor", None) # un cajón con meses dentro: el plan lo abre solo
|
||
continue
|
||
if cub is None:
|
||
if not montada and est.es_rodaje(h):
|
||
continue # llena de clips de cámara: el plan la reparte sola
|
||
if nivel == "mes":
|
||
continue # dentro de un mes, lo que no se reconoce es una pieza
|
||
apunta(h, nombre, "", "", "tiene nombre de grabación, pero no es una tarjeta volcada" if montada else "")
|
||
else:
|
||
apunta(h, nombre, cub, mot)
|
||
|
||
baja(raiz, "raiz", None)
|
||
for t in tokens.values():
|
||
if not t["cubeta"]:
|
||
_sugerir(est, resumen, raiz, t)
|
||
return sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
|
||
|
||
|
||
_ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición / diseño", "audio": "audio",
|
||
"imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"}
|
||
|
||
|
||
def _discrepa_mes(fecha, origen, anio, mes):
|
||
"""True si lo que dice el nombre del archivo no cuadra con el mes que anuncia la carpeta.
|
||
Los nombres de Blackmagic (A006_12171629) traen mes y día pero NO año: el año sale de la fecha de
|
||
modificación, que no es de fiar, así que en ese caso solo se compara el mes."""
|
||
if origen == "mtime":
|
||
return False # la fecha de modificación no prueba nada
|
||
ra, rm = int(fecha[:4]), int(fecha[5:7])
|
||
if origen == "nombre+mtime":
|
||
d = abs(rm - mes)
|
||
return min(d, 12 - d) >= 2 # solo el mes, y con dos meses de margen
|
||
return abs((ra * 12 + rm) - (anio * 12 + mes)) >= 2
|
||
|
||
|
||
def _clase_rol(rol):
|
||
return ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
|
||
"proyecto" if rol in ("proyecto", "diseno") else
|
||
"audio" if rol == "audio" else
|
||
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
|
||
"documento" if rol == "documento" else
|
||
"video" if rol in ("video", "celular_video") else "otro")
|
||
|
||
|
||
def _resumen_carpetas(con, raiz):
|
||
"""Para cada carpeta de la raíz, qué tipos de archivo tiene dentro contando subcarpetas.
|
||
Se calcula de una sola pasada sumando hacia arriba: antes se hacía una consulta por carpeta y
|
||
cada una recorría la tabla entera (abrir una raíz de 24.000 archivos tardaba 8,8 s; ahora 0,2 s)."""
|
||
agg = defaultdict(lambda: defaultdict(lambda: [0, 0]))
|
||
filas = con.execute("SELECT dir, rol, COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos "
|
||
"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol", (raiz,)).fetchall()
|
||
tope = os.path.normcase(os.path.normpath(raiz))
|
||
for f in filas:
|
||
k = _clase_rol(f["rol"])
|
||
d = os.path.normpath(f["dir"])
|
||
while True:
|
||
e = agg[os.path.normcase(d)][k]
|
||
e[0] += f["n"]
|
||
e[1] += f["b"]
|
||
if os.path.normcase(d) == tope:
|
||
break
|
||
padre = os.path.dirname(d)
|
||
if padre == d:
|
||
break
|
||
d = padre
|
||
return agg
|
||
|
||
|
||
_PARTE_FRASE = {"EXPORTADOS": "exportados", "PORTADAS": "portada", "STOCK": "stock", "IA": "IA", "GUION": "guion",
|
||
"PROYECTOS": "proyectos", "MUSICA": "música", "SFX": "sonidos"}
|
||
|
||
|
||
def _sugerir(est, resumen, raiz, t):
|
||
"""Qué es probablemente esta carpeta: por su forma, por lo que tiene dentro y por su nombre.
|
||
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible).
|
||
|
||
Lo de dentro se cuenta por número de archivos, no por peso: los clips de cámara pesan cien veces más
|
||
que todo lo demás, así que por peso casi cualquier carpeta grande «era» una grabación, y aceptar esa
|
||
propuesta la mandaba a desarmar. Una grabación de verdad el plan ya la reconoce solo; aquí nunca se
|
||
propone."""
|
||
import difflib
|
||
rutas = [os.path.join(raiz, rel) for rel in t["rutas"][:3]]
|
||
conteo = defaultdict(lambda: [0, 0])
|
||
total = [0, 0]
|
||
for r in rutas:
|
||
for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(r)), {}).items():
|
||
conteo[k][0] += n
|
||
conteo[k][1] += b
|
||
total[0] += n
|
||
total[1] += b
|
||
orden = sorted(conteo.items(), key=lambda kv: -kv[1][0])
|
||
t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas"
|
||
sug, conf, por_que = "", "", ""
|
||
fechas = list(dict.fromkeys(os.path.basename(d) for r in rutas for d in est.fechas_dentro(r)))
|
||
partes = set()
|
||
for r in rutas:
|
||
partes |= est.partes(r)
|
||
if fechas:
|
||
# a) por la forma: guarda meses o años dentro
|
||
sug, conf = "CONTENEDOR", "alta" if len(fechas) >= 2 else "media"
|
||
por_que = ("dentro guarda carpetas de otras fechas (" + ", ".join(f"«{x}»" for x in fechas[:3])
|
||
+ ("…" if len(fechas) > 3 else "") + "): es un cajón que hay que abrir, no una cosa que mover entera")
|
||
elif len(partes) >= 2:
|
||
# b) por la forma: trae las carpetas de una pieza montada
|
||
sug, conf = "PIEZA", "alta" if len(partes) >= 3 else "media"
|
||
por_que = ("dentro tiene carpetas de " + ", ".join(sorted(_PARTE_FRASE.get(p, p.lower()) for p in partes))
|
||
+ ": es una pieza montada, se mueve entera")
|
||
else:
|
||
# c) por el nombre: se parece a una palabra del diccionario
|
||
dicc = DICC["carpetas"]
|
||
cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82)
|
||
if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR", "GRABACION", "BANCO"):
|
||
sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»"
|
||
# d) por el contenido (manda si es contundente)
|
||
if total[0] >= 3 and orden:
|
||
k, (n, b) = orden[0]
|
||
frac = n / total[0]
|
||
if frac >= 0.75:
|
||
por_cont = {"proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
|
||
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
|
||
if por_cont:
|
||
if sug and sug != por_cont:
|
||
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
|
||
conf = "baja"
|
||
else:
|
||
# «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos
|
||
# los clientes: exige que sea contundente y que haya bastantes archivos.
|
||
seguro = frac >= 0.92 and n >= 8 and total[0] >= 10
|
||
sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
|
||
t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que
|
||
|
||
|
||
# ==========================================================================
|
||
# Mapa
|
||
# ==========================================================================
|
||
|
||
def mapa(raiz):
|
||
con = db()
|
||
raiz = os.path.normpath(raiz)
|
||
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
|
||
if not esc:
|
||
con.close()
|
||
return {"escaneado": False}
|
||
q = lambda sql, *a: [dict(r) for r in con.execute(sql, a).fetchall()]
|
||
total = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ?", raiz)[0]
|
||
por_ext = q("SELECT ext, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY ext ORDER BY bytes DESC LIMIT 14", raiz)
|
||
por_rol = q("SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY rol ORDER BY bytes DESC", raiz)
|
||
por_fuente = q("SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND fuente != '' GROUP BY fuente ORDER BY bytes DESC", raiz)
|
||
por_anio = q("SELECT substr(fecha,1,4) anio, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY anio ORDER BY anio", raiz)
|
||
por_mes = q("SELECT substr(fecha,1,7) mes, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado') GROUP BY mes ORDER BY mes", raiz)
|
||
basura = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_basura = 1", raiz)[0]
|
||
proxies = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", raiz)[0]
|
||
dd = duplicados(raiz)
|
||
dup_ej = [{"tipo": g["tipo"], "nombre": g["nombre"], "bytes": g["bytes"], "copias": len(g["mueven"]) + 1,
|
||
"queda": os.path.relpath(g["queda"], raiz), "rutas": [os.path.relpath(r, raiz) for r in g["mueven"]]} for g in dd["grupos"][:12]]
|
||
raw = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND rol = 'camara_raw'", raiz)[0]
|
||
grandes = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND bytes > 1000000000 AND rol IN ('video','camara_video') AND es_proxy = 0", raiz)[0]
|
||
sin_fecha = q("SELECT COUNT(*) n FROM archivos WHERE raiz = ? AND fecha_origen = 'mtime' AND rol IN ('camara_raw','camara_video')", raiz)[0]
|
||
# árbol de 2 niveles para el treemap
|
||
hijos = q("SELECT * FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC",
|
||
raiz, esc_like(con_sep(raiz)) + "%", esc_like(con_sep(raiz)) + "%" + esc_like(os.sep) + "%")
|
||
arbol = []
|
||
for h in hijos:
|
||
nietos = q("SELECT nombre, n, bytes FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC LIMIT 40",
|
||
raiz, esc_like(h["ruta"] + os.sep) + "%", esc_like(h["ruta"] + os.sep) + "%" + esc_like(os.sep) + "%")
|
||
cub, _ = R.cubeta_carpeta(h["nombre"], DICC["carpetas"])
|
||
if R.detectar_anio(h["nombre"]):
|
||
cub = "ANIO"
|
||
elif R.norm(h["nombre"]) in R.PAPELERAS_NORM or R.norm(h["nombre"]) == R.norm(R.DUPLICADOS):
|
||
cub = "APARTADO" # carpetas de la propia app: no son «sin reconocer»
|
||
arbol.append({"nombre": h["nombre"], "n": h["n"], "bytes": h["bytes"], "cubeta": cub or "", "hijos": nietos,
|
||
"anio": h["anio_moda"], "mes": h["mes_moda"]})
|
||
sueltos_raiz = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, raiz)[0]
|
||
con.close()
|
||
return {
|
||
"escaneado": True, "raiz": raiz, "escaneo": dict(esc), "total": total, "por_ext": por_ext, "por_rol": por_rol,
|
||
"por_fuente": por_fuente, "por_anio": por_anio, "por_mes": por_mes, "basura": basura, "proxies": proxies,
|
||
"duplicados": {"grupos": len(dd["grupos"]), "n": dd["n"], "bytes": dd["bytes"], "identicos": dd["identicos"], "versiones": dd["versiones"],
|
||
"por_comparar": dd["por_comparar"], "ejemplos": dup_ej}, "raw": raw, "grandes": grandes,
|
||
"sin_fecha": sin_fecha, "arbol": arbol, "sueltos_raiz": sueltos_raiz,
|
||
}
|
||
|
||
|
||
def buscar_carpeta(nombre, profundidad=3):
|
||
"""Busca una carpeta por nombre en todos los discos (hasta 3 niveles), para el arrastrar-y-soltar."""
|
||
objetivo = R.norm(nombre)
|
||
if not objetivo:
|
||
return []
|
||
saltar = {"#recycle", "@eadir", "windows", "program files", "program files (x86)", "programdata", "$recycle.bin", "appdata", "node_modules", "system volume information"}
|
||
hits = []
|
||
inicio = time.time()
|
||
|
||
def rec(path, nivel):
|
||
if time.time() - inicio > 25 or len(hits) >= 20:
|
||
return
|
||
try:
|
||
with os.scandir(path) as it:
|
||
ents = [e for e in it if e.is_dir(follow_symlinks=False) and not e.name.startswith((".", "$", "~"))]
|
||
except OSError:
|
||
return
|
||
for e in ents:
|
||
if R.norm(e.name) == objetivo:
|
||
hits.append(e.path)
|
||
if nivel < profundidad:
|
||
for e in ents:
|
||
if e.name.lower() not in saltar:
|
||
rec(e.path, nivel + 1)
|
||
|
||
for letra in "DEFGHIJKLMNOPQRSTUVWXYZC":
|
||
raiz = letra + ":" + os.sep
|
||
if os.path.exists(raiz):
|
||
rec(raiz, 1)
|
||
return hits
|
||
|
||
|
||
_DISCOS_CACHE = {"ts": 0, "datos": None}
|
||
|
||
|
||
def discos():
|
||
if time.time() - _DISCOS_CACHE["ts"] < 120 and _DISCOS_CACHE["datos"]:
|
||
return _DISCOS_CACHE["datos"]
|
||
unc = {}
|
||
try:
|
||
out = subprocess.run(["net", "use"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA).stdout
|
||
for l in out.splitlines():
|
||
m = re.search(r"\b([A-Z]):\s+(\\\\\S+)", l)
|
||
if m:
|
||
unc[m.group(1) + ":"] = m.group(2)
|
||
except Exception: # noqa
|
||
pass
|
||
alias = {}
|
||
for cli, info in CONFIG["clientes"].items():
|
||
for a in info.get("alias", []) + [cli]:
|
||
alias[R.norm(a)] = cli
|
||
res = []
|
||
for letra in "CDEFGHIJKLMNOPQRSTUVWXYZ":
|
||
d = letra + ":"
|
||
raiz = d + os.sep
|
||
if not os.path.exists(raiz):
|
||
continue
|
||
try:
|
||
u = shutil.disk_usage(raiz)
|
||
except OSError:
|
||
continue
|
||
carpetas = []
|
||
try:
|
||
with os.scandir(raiz) as it:
|
||
for e in it:
|
||
if e.is_dir(follow_symlinks=False) and not e.name.startswith(("#", ".", "$", "@")):
|
||
cli = alias.get(R.norm(e.name))
|
||
carpetas.append({"nombre": e.name, "cliente": cli})
|
||
except OSError:
|
||
pass
|
||
cfg = CONFIG["discos"].get(d, {})
|
||
res.append({"letra": d, "unc": unc.get(d, ""), "nombre": cfg.get("nombre", ""), "nivel": cfg.get("nivel", "" if unc.get(d) else "local"),
|
||
"total": u.total, "libre": u.free, "carpetas": sorted(carpetas, key=lambda c: c["nombre"].lower())})
|
||
# dónde vive cada cliente
|
||
por_cliente = defaultdict(list)
|
||
for disco in res:
|
||
for c in disco["carpetas"]:
|
||
if c["cliente"]:
|
||
por_cliente[c["cliente"]].append(f"{disco['letra']}\\{c['nombre']}")
|
||
for r in CONFIG["raices"]:
|
||
por_cliente[r["cliente"]].append(r["ruta"])
|
||
datos = {"discos": res, "clientes": {k: sorted(set(v)) for k, v in por_cliente.items()}}
|
||
_DISCOS_CACHE.update(ts=time.time(), datos=datos)
|
||
return datos
|
||
|
||
|
||
# ==========================================================================
|
||
# Optimización: análisis (ffprobe), candidatos, cola, motores
|
||
# ==========================================================================
|
||
|
||
ANALISIS = {} # raiz -> progreso
|
||
|
||
|
||
def ffprobe(ruta):
|
||
cmd = [ruta_herramienta("ffprobe"), "-v", "error", "-show_entries",
|
||
"stream=codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,bit_rate,nb_frames",
|
||
"-show_entries", "format=duration,bit_rate", "-of", "json", ruta]
|
||
try:
|
||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA).stdout
|
||
j = json.loads(out or "{}")
|
||
except Exception: # noqa
|
||
return None
|
||
v = next((s for s in j.get("streams", []) if s.get("codec_type") == "video"), None)
|
||
a = next((s for s in j.get("streams", []) if s.get("codec_type") == "audio"), None)
|
||
fmt = j.get("format", {})
|
||
if not v:
|
||
return None
|
||
fr = v.get("r_frame_rate", "0/1")
|
||
try:
|
||
num, den = fr.split("/")
|
||
fps = float(num) / float(den) if float(den) else 0
|
||
except ValueError:
|
||
fps = 0
|
||
dur = float(fmt.get("duration") or 0)
|
||
kbps = int(int(fmt.get("bit_rate") or v.get("bit_rate") or 0) / 1000)
|
||
pix = v.get("pix_fmt") or ""
|
||
return {
|
||
"codec": v.get("codec_name") or "", "perfil": v.get("profile") or "", "pix_fmt": pix,
|
||
"ancho": int(v.get("width") or 0), "alto": int(v.get("height") or 0), "fps": round(fps, 3), "dur": dur,
|
||
"kbps": kbps, "alfa": 1 if ("a" in pix and pix not in ("yuva", "") and ("rgba" in pix or "argb" in pix or "bgra" in pix or "yuva" in pix)) else 0,
|
||
"acodec": (a or {}).get("codec_name") or "",
|
||
"pistas_audio": sum(1 for x in j.get("streams", []) if x.get("codec_type") == "audio"),
|
||
}
|
||
|
||
|
||
HILOS_ANALISIS = 8 # ffprobe pasa casi todo el tiempo esperando al disco de red
|
||
|
||
|
||
def analizar(raiz, min_mb, reintentar=False):
|
||
"""Lee con ffprobe los clips que faltan (códec, duración, resolución). Va en paralelo porque cada
|
||
lectura es sobre todo espera de red: medido, 0,23 s por clip; 15.423 clips en serie pasan de la hora
|
||
y con ocho a la vez bajan a unos ocho minutos.
|
||
|
||
Con `reintentar` también vuelve sobre los que no se pudieron leer (analizado = 2), por si el problema
|
||
era un ffprobe viejo o una unidad que estaba desconectada."""
|
||
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time(),
|
||
"cancelar": False, "ilegibles": 0}
|
||
con = db()
|
||
estados = "(0, 2)" if reintentar else "(0)"
|
||
lee_braw = bool(decodificador_braw().get("ok"))
|
||
# los BRAW que solo leyó ffprobe se miden otra vez con el decodificador: se les nota en que no tienen
|
||
# «perfil», que es donde queda apuntada su curva
|
||
remedir = " OR (ext = '.braw' AND analizado = 1 AND COALESCE(perfil, '') = '')" if lee_braw else ""
|
||
pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND (analizado IN {estados}{remedir}) AND es_proxy = 0 "
|
||
"AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock') "
|
||
"ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||
prog["total"] = len(pend)
|
||
try:
|
||
import concurrent.futures as cf
|
||
lote = []
|
||
|
||
def guardar(lote):
|
||
# si la base está ocupada (otro proceso guardando, por ejemplo después de «Aplicar»), se espera y
|
||
# se reintenta: antes «Buscar clips» se rendía al minuto con «database is locked»
|
||
for intento in range(40):
|
||
try:
|
||
_guardar(lote)
|
||
prog.pop("esperando", None)
|
||
return
|
||
except sqlite3.OperationalError as ex:
|
||
if "locked" not in str(ex) or intento == 39:
|
||
raise
|
||
prog["esperando"] = "la base está ocupada guardando otros cambios; se espera"
|
||
time.sleep(3)
|
||
|
||
def _guardar(lote):
|
||
with con:
|
||
for ruta, info in lote:
|
||
if info:
|
||
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, "
|
||
"kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
|
||
(info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"],
|
||
info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"],
|
||
info.get("pistas_audio", 1), ruta))
|
||
else:
|
||
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (ruta,))
|
||
|
||
def leer(f):
|
||
info = ffprobe(f["ruta"])
|
||
if info and f["ext"] == ".braw":
|
||
info["codec"] = "braw" # ffprobe lee el BRAW pero no le pone nombre al códec
|
||
m = medir_braw(f["ruta"]) if lee_braw else {}
|
||
if m.get("ok"):
|
||
info.update(ancho=m["ancho"], alto=m["alto"], fps=m["fps"] or info["fps"], dur=m["dur"] or info["dur"], perfil=_curva(m))
|
||
return f["ruta"], info
|
||
|
||
with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool:
|
||
for ruta, info in pool.map(leer, pend):
|
||
if prog["cancelar"]:
|
||
break
|
||
if info is None:
|
||
prog["ilegibles"] += 1
|
||
lote.append((ruta, info))
|
||
prog["hechos"] += 1
|
||
if len(lote) >= 50: # se guarda por tandas: una transacción por clip es mucho más lento
|
||
guardar(lote)
|
||
lote = []
|
||
guardar(lote)
|
||
prog["estado"] = "cancelado" if prog["cancelar"] else "listo"
|
||
if prog["ilegibles"]:
|
||
prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)"
|
||
except Exception as ex: # noqa
|
||
prog["estado"] = "error"
|
||
prog["error"] = ("la base de datos siguió ocupada guardando otros cambios más de dos minutos; vuelve a pulsar «Buscar clips»"
|
||
if "locked" in str(ex) else 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))
|
||
|
||
|
||
# Cuánto pesa lo que sale, según de dónde viene. La cola codifica a calidad constante (cq) con un techo de 1,8
|
||
# veces la tasa objetivo: lo que sale depende de cómo venía el clip, no de la tasa objetivo. Medido el
|
||
# 14-oct-2026 sobre las 286 conversiones hechas (Galeano, Línea Gene, Diana Méndez), lo que pesó cada una:
|
||
# - lo que guarda cada cuadro entero (BRAW, XAVC-I): entre el 1 y el 6 % (entre 0,3 y 0,85 veces el objetivo);
|
||
# - un H.264 de cámara: entre el 9 y el 26 %; el de 4.6K a 33 Mb/s (5-oct), la mitad;
|
||
# - un AV1 de cámara o de móvil (85-99 Mb/s): entre el 9 y el 11 %, su codificador gasta mucho;
|
||
# - un H.265 de cámara: entre el 85 y el 99 %, aunque fuera holgado. De ahí no se baja sin quitar calidad.
|
||
# Así que lo comprimido entre cuadros sale como mucho a 0,6 veces su tasa (MPEG-4 y MPEG-2, menos) y nunca por
|
||
# encima del objetivo; un H.265, a 0,93 veces la suya, hasta el techo. Hasta la 3.8 se miraba solo si el origen
|
||
# pasaba de 1 vez (H.264) o 2 veces (H.265, AV1, VP9) el objetivo: once AV1 de Línea Gene que habrían quedado en
|
||
# la décima parte no se ofrecían, y en cambio salían H.265 que luego no adelgazaban.
|
||
SALE_A = {"h264": 0.6, "av1": 0.6, "vp9": 0.6, "mpeg4": 0.45, "mpeg2video": 0.35}
|
||
NOMBRE_CODEC = {"hevc": "H.265", "av1": "AV1", "vp9": "VP9", "h264": "H.264", "mpeg4": "MPEG-4", "mpeg2video": "MPEG-2"}
|
||
|
||
|
||
def _kbps_salida(f, objetivo):
|
||
"""La tasa de video (kb/s) con que saldrá un clip a H.265, según su códec y su tasa (ver SALE_A)."""
|
||
src = f["kbps"] or 0
|
||
if not src or "intra" in (f["perfil"] or "").lower():
|
||
return objetivo
|
||
if f["codec"] == "hevc":
|
||
return min(src * 0.93, objetivo * 1.8)
|
||
if f["codec"] in SALE_A:
|
||
return min(src * SALE_A[f["codec"]], objetivo)
|
||
return objetivo
|
||
|
||
|
||
def ahorro_minimo():
|
||
"""Lo que tiene que ahorrar un clip convertido para que se ofrezca, en %. Munka, 14-oct-2026: «si un archivo
|
||
se puede optimizar así sea un 30 % se convierta, la idea es aprovechar lo máximo el espacio»."""
|
||
try:
|
||
return max(0, min(90, int(_opt("ahorro_minimo", 30))))
|
||
except (TypeError, ValueError):
|
||
return 30
|
||
|
||
|
||
def _mbps(kbps):
|
||
return f"{kbps / 1000:.1f}".replace(".", ",") if kbps < 10000 else f"{kbps / 1000:.0f}"
|
||
|
||
|
||
def _salio_del_convertidor(f, salidas):
|
||
"""Lleva el sufijo del convertidor y además está en el códec que ese sufijo produce: un «Entrevista_HQ.mp4»
|
||
en H.264, o un «clip_proxy.mp4» de la cámara, no los hizo este programa."""
|
||
if os.path.normcase(os.path.normpath(f["ruta"])) in salidas:
|
||
return True
|
||
if not R.es_conversion(f["nombre"]):
|
||
return False
|
||
base = os.path.splitext(f["nombre"])[0].lower()
|
||
sufijo = next((s for s in R.SUFIJOS_CONVERSION if base.endswith(s)), "")
|
||
return f["codec"] == {"_h264": "h264", "_proxy": "prores", "_lt": "prores", "_hq": "prores"}.get(sufijo, "hevc")
|
||
|
||
|
||
def estimar_bytes(f, perfil, formato):
|
||
"""peso aproximado de la salida para un archivo del inventario"""
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
dur = f["dur"] or 0
|
||
if fm.get("mbps_1080p30"):
|
||
pix = (f["ancho"] or 1920) * (f["alto"] or 1080) * (f["fps"] or 30)
|
||
return int(fm["mbps_1080p30"] * 1e6 / 8 * dur * pix / (1920 * 1080 * 30))
|
||
obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)
|
||
if fm["codec"] == "hevc":
|
||
obj = _kbps_salida(f, obj)
|
||
return int(obj * 1000 / 8 * dur) + int(256000 / 8 * dur)
|
||
|
||
|
||
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit", ahorro=None):
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
try:
|
||
ahorro = ahorro_minimo() if ahorro in (None, "") else max(0, min(90, int(ahorro)))
|
||
except (TypeError, ValueError):
|
||
ahorro = ahorro_minimo()
|
||
lee_braw = bool(decodificador_braw().get("ok"))
|
||
reubicar_trabajos(raiz)
|
||
con = db()
|
||
tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad
|
||
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
|
||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
|
||
"AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||
# solo bloquea el clip lo que está a medias EN ESTE MISMO formato: un clip ya pasado a ProRes debe
|
||
# poder convertirse también a H.265, y uno que falló debe poder reintentarse.
|
||
en_cola = {r["ruta"]: r["estado"] for r in con.execute(
|
||
"SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')",
|
||
(formato,)).fetchall()}
|
||
# lo que ya salió del convertidor no vuelve a entrar. Medido el 4-oct-2026 sobre el material real: 22
|
||
# clips estaban convertidos dos veces y algunos tres («…_10bit_10bit_10bit.mp4»), cada pasada con su
|
||
# pérdida y sus horas, porque una conversión con buen bitrate no pasaba por «ya está en HEVC eficiente».
|
||
salidas = {os.path.normcase(os.path.normpath(r["salida"])) for r in con.execute(
|
||
"SELECT salida FROM trabajos WHERE estado IN ('listo','verificado','reemplazado') AND salida IS NOT NULL").fetchall()}
|
||
# y lo que ya se probó con esta misma calidad y no compensó, tampoco
|
||
probados = {r["ruta"]: r["log"] or "" for r in con.execute(
|
||
"SELECT ruta, log FROM trabajos WHERE formato = ? AND perfil = ? AND estado = 'descartado'", (formato, perfil)).fetchall()}
|
||
con.close()
|
||
out = []
|
||
for f in filas:
|
||
motivo_no = ""
|
||
# los BRAW los lee el decodificador de Blackmagic; solo si falta se intenta por el guion de Resolve
|
||
motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg"
|
||
obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
|
||
est = estimar_bytes(f, perfil, formato)
|
||
# el ahorro, contra lo que pesa el clip por su tasa (es lo mismo que su peso, y no cuenta lo que no es video)
|
||
base = f["kbps"] * 1000 / 8 * f["dur"] if f["kbps"] and f["dur"] else f["bytes"]
|
||
gana = round((1 - est / base) * 100) if base else 100
|
||
if _salio_del_convertidor(f, salidas):
|
||
motivo_no = "ya es una conversión hecha con este programa"
|
||
elif f["es_stock"]:
|
||
motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)"
|
||
elif f["alfa"]:
|
||
motivo_no = "tiene canal alfa (HEVC lo perdería)"
|
||
elif f["ruta"] in probados:
|
||
motivo_no = "ya se probó con esta calidad: " + probados[f["ruta"]].replace("no compensa: ", "").split(". ")[0]
|
||
elif os.sep + R.DUPLICADOS + os.sep in f["ruta"]:
|
||
motivo_no = "está apartado como repetido (Duplicados): mejor borrarlo que convertirlo"
|
||
elif fm["codec"] == "hevc" and f["dur"] and gana < ahorro:
|
||
motivo_no = (f"convertido ahorraría solo un {max(gana, 0)} % ({NOMBRE_CODEC.get(f['codec'], f['codec'])} a {_mbps(f['kbps'] or 0)} Mb/s): "
|
||
f"se convierte desde un {ahorro} %")
|
||
elif motor == "resolve" and formato != "h265_10bit":
|
||
motivo_no = "sin el decodificador de Blackmagic los BRAW van por Resolve, y solo salen a H.265 10 bits"
|
||
elif tope and not es_viejo(f, tope):
|
||
motivo_no = f"tiene menos de {meses} meses"
|
||
elif f["contexto"] == "EXPORTADOS" and not incluir_exportados:
|
||
motivo_no = "es un exportado / master"
|
||
elif not f["ancho"] or not f["dur"]:
|
||
motivo_no = "no se pudo leer (ffprobe)"
|
||
out.append({
|
||
"ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"],
|
||
"pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"],
|
||
"kbps_objetivo": obj, "estimado": est, "motor": motor, "excluido": motivo_no, "fecha": f["fecha"], "rol": f["rol"],
|
||
"contexto": f["contexto"], "en_cola": en_cola.get(f["ruta"], ""),
|
||
})
|
||
return out
|
||
|
||
|
||
def salida_para(ruta, raiz, formato="h265_10bit"):
|
||
o = CONFIG["optimizar"]
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
base, _ = os.path.splitext(ruta)
|
||
sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
|
||
if o.get("salida") == "espejo":
|
||
rel = os.path.relpath(base, raiz)
|
||
# el sufijo también aquí: sin él, H.265 y H.264 del mismo clip iban al mismo archivo
|
||
return os.path.join(raiz, "_Archivo", rel + sufijo + fm["ext"])
|
||
return base + sufijo + fm["ext"]
|
||
|
||
|
||
def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
|
||
if formato not in FORMATOS:
|
||
formato = "h265_10bit"
|
||
fm = FORMATOS[formato]
|
||
lee_braw = bool(decodificador_braw().get("ok"))
|
||
con = db()
|
||
ids = []
|
||
lote, movidos, errores, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
|
||
with con:
|
||
for r in rutas:
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone()
|
||
if not f:
|
||
continue
|
||
ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r, formato)).fetchone()
|
||
if ya:
|
||
continue
|
||
motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg"
|
||
if motor == "resolve" and formato != "h265_10bit":
|
||
continue # por el guion de Resolve los BRAW solo salen a H.265 10 bits
|
||
salida = salida_para(r, raiz, formato)
|
||
if os.path.exists(salida):
|
||
previo = con.execute("SELECT * FROM trabajos WHERE ruta = ? AND salida = ? AND estado = 'descartado' ORDER BY id DESC",
|
||
(r, salida)).fetchone()
|
||
if previo:
|
||
try:
|
||
if _apartar_convertido(dict(previo), lote, movidos):
|
||
raiz_desc = raiz_desc or previo["raiz"]
|
||
except OSError as ex:
|
||
errores.append(f"{os.path.basename(salida)}: {ex}")
|
||
# el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola.
|
||
otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone()
|
||
if otro or os.path.exists(salida):
|
||
continue
|
||
kb = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
|
||
# los intentos fallidos de este mismo clip se sustituyen por el nuevo (el 5-oct-2026 había 174
|
||
# filas de error para 87 clips: cada tanda dejaba las suyas)
|
||
con.execute("DELETE FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('error','cancelado','descartado')", (r, formato))
|
||
cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz, formato))
|
||
ids.append(cur.lastrowid)
|
||
con.close()
|
||
_cerrar_lote(lote, raiz_desc or raiz, movidos, errores)
|
||
return ids
|
||
|
||
|
||
# empezo: cuándo arrancó el clip que va; ritmo: (segundos de clip, segundos reales) de cada clip acabado en
|
||
# esta tanda, para decir cuánto falta
|
||
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": [],
|
||
"esperando": "", "hechos_ids": [], "ultima": None, "actuales": {}, "saltados": 0}
|
||
|
||
|
||
def _opt(k, defecto=None):
|
||
v = (CONFIG.get("optimizar") or {}).get(k)
|
||
return defecto if v is None else v
|
||
|
||
|
||
def en_horario(ahora=None):
|
||
"""Si la cola tiene horario, ¿estamos dentro? (True, "") o (False, "hasta las 20:00"). El horario puede
|
||
pasar por medianoche (20:00 a 07:00)."""
|
||
h = _opt("horario") or {}
|
||
if not h.get("activo"):
|
||
return True, ""
|
||
try:
|
||
d1, m1 = map(int, (h.get("desde") or "20:00").split(":"))
|
||
d2, m2 = map(int, (h.get("hasta") or "07:00").split(":"))
|
||
except ValueError:
|
||
return True, ""
|
||
t = time.localtime(ahora or time.time())
|
||
x, a, b = t.tm_hour * 60 + t.tm_min, d1 * 60 + m1, d2 * 60 + m2
|
||
dentro = (a <= x < b) if a < b else (x >= a or x < b)
|
||
return (True, "") if dentro else (False, f"hasta las {h.get('desde') or '20:00'}")
|
||
|
||
|
||
APPS_EDICION = {"resolve.exe": "DaVinci Resolve", "adobe premiere pro.exe": "Premiere Pro", "afterfx.exe": "After Effects"}
|
||
|
||
|
||
def editando():
|
||
"""Qué programa de edición está usando la tarjeta ahora mismo, o "". Lo dice nvidia-smi pmon (uso de
|
||
cómputo, codificador y decodificador por proceso); si no hay nvidia-smi, basta con que el programa esté
|
||
abierto. Solo cuenta si está activado optimizar.pausar_si_edito."""
|
||
if not _opt("pausar_si_edito", True):
|
||
return ""
|
||
try:
|
||
r = subprocess.run(["nvidia-smi", "pmon", "-c", "1", "-s", "u"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
|
||
if r.returncode == 0 and r.stdout:
|
||
for linea in r.stdout.splitlines():
|
||
partes = linea.split(None, 9)
|
||
if linea.startswith("#") or len(partes) < 10:
|
||
continue
|
||
nombre = partes[9].strip().lower()
|
||
uso = [float(u) for u in partes[3:7] if u.replace(".", "", 1).isdigit()]
|
||
for exe, bonito in APPS_EDICION.items():
|
||
if nombre and exe.startswith(nombre[:10]) and any(u > 5 for u in uso): # pmon recorta el nombre
|
||
return bonito
|
||
return ""
|
||
except (OSError, subprocess.SubprocessError, ValueError):
|
||
pass
|
||
try:
|
||
r = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
|
||
bajo = (r.stdout or "").lower()
|
||
for exe, bonito in APPS_EDICION.items():
|
||
if f'"{exe}"' in bajo:
|
||
return bonito
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
return ""
|
||
|
||
|
||
def _esperar_turno():
|
||
"""Antes de cada clip: si hay horario y estamos fuera, o si un programa de edición está usando la
|
||
tarjeta, la cola espera (sigue activa) y lo dice. Nunca corta un clip a medias. False si la detuvieron."""
|
||
while COLA["activa"] and not COLA["cancelar"]:
|
||
dentro, hasta = en_horario()
|
||
motivo = "" if dentro else f"espera {hasta}, por el horario de la cola"
|
||
if not motivo:
|
||
app = editando()
|
||
motivo = f"espera a que {app} deje la tarjeta" if app else ""
|
||
COLA["esperando"] = motivo
|
||
if not motivo:
|
||
return True
|
||
COLA["mensaje"] = motivo
|
||
time.sleep(20)
|
||
COLA["esperando"] = ""
|
||
return False
|
||
|
||
|
||
def notificar(titulo, texto):
|
||
"""Un aviso de Windows (la burbuja de la esquina), con la identidad del programa. Va en un PowerShell
|
||
aparte y sin esperar: si falla, no pasa nada."""
|
||
if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"):
|
||
return False
|
||
esc_ = lambda s: str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace("'", "'")
|
||
ps = ("[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n"
|
||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null\n"
|
||
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument\n"
|
||
f"$xml.LoadXml('<toast><visual><binding template=\"ToastGeneric\"><text>{esc_(titulo)}</text><text>{esc_(texto)}</text></binding></visual></toast>')\n"
|
||
"$t = New-Object Windows.UI.Notifications.ToastNotification $xml\n"
|
||
"try { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Labstream.OrganiLAB').Show($t) }\n"
|
||
"catch { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($t) }\n")
|
||
try:
|
||
ruta = os.path.join(DATOS, "aviso.ps1")
|
||
with open(ruta, "w", encoding="utf-8-sig") as fh:
|
||
fh.write(ps.replace("\\n", "\n"))
|
||
subprocess.Popen(["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ruta],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=SIN_VENTANA)
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def _resumen_tanda():
|
||
"""Al acabar la cola: qué se hizo en esta tanda (clips, pesos, tiempo, nota media, fallos, por carpeta).
|
||
Queda en la bitácora (op «tanda»), se recuerda para la interfaz y, si está activado, se avisa."""
|
||
ids = list(COLA.get("hechos_ids") or [])
|
||
if not ids:
|
||
return None
|
||
con = db()
|
||
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||
f"WHERE t.id IN ({','.join('?' * len(ids))})", ids).fetchall()]
|
||
con.close()
|
||
bien = [t for t in ts if t["estado"] in ("listo", "verificado", "reemplazado")]
|
||
notas = [t["nota"] for t in bien if t.get("nota")]
|
||
carpetas = {}
|
||
for t in bien:
|
||
try:
|
||
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
|
||
except ValueError:
|
||
rel = os.path.dirname(t["ruta"])
|
||
c = carpetas.setdefault(rel, {"carpeta": rel, "clips": 0, "bytes_in": 0, "bytes_out": 0})
|
||
c["clips"] += 1
|
||
c["bytes_in"] += t["bytes_in"] or 0
|
||
c["bytes_out"] += t["bytes_out"] or 0
|
||
raices = sorted({t["raiz"] for t in ts if t.get("raiz")})
|
||
seg = round(time.time() - (COLA["inicio"] or time.time()))
|
||
r = {"lote": "tanda-" + time.strftime("%Y%m%d-%H%M%S"), "inicio": COLA["inicio"], "seg": seg, "clips": len(bien), "pedidos": len(ts),
|
||
"bytes_in": sum(t["bytes_in"] or 0 for t in bien), "bytes_out": sum(t["bytes_out"] or 0 for t in bien),
|
||
"fallos": sum(1 for t in ts if t["estado"] in ("error", "cancelado")), "descartados": sum(1 for t in ts if t["estado"] == "descartado"),
|
||
"reemplazados": sum(1 for t in ts if t["estado"] == "reemplazado"), "verificados": sum(1 for t in ts if t["estado"] == "verificado"),
|
||
"nota": round(sum(notas) / len(notas), 1) if notas else None, "nota_min": min(notas) if notas else None,
|
||
"raiz": raices[0] if len(raices) == 1 else "", "raices": raices,
|
||
"cliente": ", ".join((raiz_cfg(x) or {}).get("cliente") or os.path.basename(x) for x in raices),
|
||
"carpetas": sorted(carpetas.values(), key=lambda c: -c["bytes_in"]), "ids": ids}
|
||
bitacora({"op": "tanda", **r})
|
||
COLA["ultima"] = dict(r, ts=time.time())
|
||
if _opt("avisar", True) and (r["clips"] or r["fallos"]):
|
||
que = f"{r['clips']} clip{'s' if r['clips'] != 1 else ''} convertido{'s' if r['clips'] != 1 else ''}"
|
||
if r["clips"]:
|
||
que += f" y verificado{'s' if r['clips'] != 1 else ''} en {_tiempo_llano(seg)}. " + ("Ya reemplazados." if r["reemplazados"] == r["clips"] else "Falta reemplazar.")
|
||
if r["fallos"]:
|
||
que += f" {r['fallos']} fallaron."
|
||
notificar("OrganiLAB terminó" if r["clips"] else "OrganiLAB: la tanda no salió", que)
|
||
return r
|
||
|
||
|
||
def _tiempo_llano(seg):
|
||
seg = int(seg or 0)
|
||
if seg < 60:
|
||
return f"{seg} s"
|
||
if seg < 3600:
|
||
return f"{seg // 60} min"
|
||
return f"{seg // 3600} h {round(seg % 3600 / 60)} min"
|
||
|
||
|
||
def _ultima_tanda_guardada():
|
||
for e in reversed(leer_bitacora(3000)):
|
||
if e.get("op") == "tanda":
|
||
return e
|
||
return {}
|
||
|
||
|
||
def tanda_csv(lote):
|
||
"""Los clips de una tanda, en CSV (para el cliente o para mirar en Excel)."""
|
||
import csv, io
|
||
e = next((x for x in reversed(leer_bitacora(3000)) if x.get("op") == "tanda" and x.get("lote") == lote), None)
|
||
if not e:
|
||
return None
|
||
ids = e.get("ids") or []
|
||
con = db()
|
||
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||
f"WHERE t.id IN ({','.join('?' * len(ids))}) ORDER BY t.id", ids).fetchall()] if ids else []
|
||
con.close()
|
||
s = io.StringIO()
|
||
w = csv.writer(s, delimiter=";", lineterminator="\n")
|
||
w.writerow(["clip", "carpeta", "estado", "antes (MB)", "después (MB)", "ahorro %", "duración (s)", "nota de calidad", "formato", "salida"])
|
||
for t in ts:
|
||
try:
|
||
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
|
||
except ValueError:
|
||
rel = os.path.dirname(t["ruta"])
|
||
bi, bo = t["bytes_in"] or 0, t["bytes_out"] or 0
|
||
w.writerow([os.path.basename(t["ruta"]), rel, EST_CSV.get(t["estado"], t["estado"]), round(bi / 1e6, 1), round(bo / 1e6, 1) if bo else "",
|
||
round((1 - bo / bi) * 100) if bi and bo else "", round(t["dur"] or 0, 1) if t.get("dur") else "", t.get("nota") or "",
|
||
formato_de(t), t["salida"] or ""])
|
||
return s.getvalue()
|
||
|
||
|
||
EST_CSV = {"pendiente": "en espera", "corriendo": "convirtiendo", "listo": "convertido", "verificado": "verificado", "reemplazado": "reemplazado",
|
||
"error": "falló", "cancelado": "cancelado", "descartado": "no compensa"}
|
||
|
||
|
||
def set_trabajo(id_, **kw):
|
||
con = db()
|
||
with con:
|
||
sets = ", ".join(f"{k} = ?" for k in kw)
|
||
con.execute(f"UPDATE trabajos SET {sets} WHERE id = ?", (*kw.values(), id_))
|
||
con.close()
|
||
|
||
|
||
def formato_de(t):
|
||
"""el formato de un trabajo, tolerando filas viejas sin columna"""
|
||
try:
|
||
f = t["formato"] if "formato" in t.keys() else None
|
||
except AttributeError:
|
||
f = t.get("formato")
|
||
return f or "h265_10bit"
|
||
|
||
|
||
def _motor_de(formato, chroma, ancho=0, alto=0):
|
||
"""Con qué se codifica un formato en este equipo, «gpu» o «cpu». Si no se puede: None y el porqué."""
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
_m = motores()
|
||
motor = _m["formatos"].get(formato)
|
||
lado = 4096 if fm["codec"] == "h264" else 8192
|
||
if motor == "gpu" and max(ancho or 0, alto or 0) > lado:
|
||
# la tarjeta no codifica imágenes de más de 4096 píxeles de lado en H.264 (un clip de 4.6K ya se pasa:
|
||
# «No capable devices found») ni de más de 8192 en H.265 (un BRAW de 12K): esos van por CPU
|
||
motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None
|
||
if motor is None:
|
||
return None, f"la tarjeta gráfica no codifica {fm['codec'].upper()} de más de {lado} píxeles de lado y no hay codificador por CPU"
|
||
if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"):
|
||
# la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU
|
||
motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None
|
||
if motor is None:
|
||
return None, "esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0"
|
||
if not motor:
|
||
return None, f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)"
|
||
return motor, ""
|
||
|
||
|
||
def _args_video(t, fm, motor, fps):
|
||
"""Los argumentos de ffmpeg que codifican la imagen —los mismos venga el clip de un archivo o del lector
|
||
de BRAW— y el formato de píxel en que la quiere el codificador."""
|
||
chroma = t["chroma"] or "420"
|
||
cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"]
|
||
# el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir»
|
||
# salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca)
|
||
cq = max(14, min(38, round(cq + fm.get("cq_mas", 0))))
|
||
maxrate = int(t["kbps"] * 1.8)
|
||
gop = str(int(round((fps or 30) * 2)))
|
||
diez = fm["bits"] == 10
|
||
if fm["codec"] == "prores":
|
||
# sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no
|
||
# salía a la tasa de un HQ (que es justo lo que promete el formato)
|
||
return ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"], "yuv422p10le"
|
||
if motor == "gpu":
|
||
pix = ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p"
|
||
# «uhq» es el ajuste de más rendimiento de las tarjetas recientes. Medido el 5-oct-2026 con tres clips
|
||
# del estudio (BRAW de la URSA, BRAW vertical de la Pocket y XAVC-I 4K60 de la Sony), cada uno contra
|
||
# su referencia sin pérdida y a la misma calidad pedida: pesa entre un 19 y un 55 % menos que «hq» con
|
||
# la misma nota (VMAF −0,4 a +0,9 sobre 100), y en el cuadro peor sale igual o mejor en dos de los
|
||
# tres. No deja fijar los cuadros B, y solo se usa donde se midió: H.265 de 10 bits en 4:2:0.
|
||
uhq = bool(fm["codec"] == "hevc" and diez and chroma != "422" and motores()["gpu"].get("uhq"))
|
||
args = ["-c:v", fm["gpu"], "-pix_fmt", pix,
|
||
"-preset", "p6", "-tune", "uhq" if uhq else "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0",
|
||
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k",
|
||
"-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32"]
|
||
args += ([] if uhq else ["-bf", "3", "-b_ref_mode", "middle"]) + ["-g", gop]
|
||
if fm["codec"] == "hevc":
|
||
args += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"]
|
||
else:
|
||
args += ["-profile:v", "high"]
|
||
return args, pix
|
||
pix = ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p"
|
||
args = ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), "-pix_fmt", pix,
|
||
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop]
|
||
if fm["codec"] == "hevc":
|
||
args += ["-tag:v", "hvc1"]
|
||
if diez:
|
||
args += ["-profile:v", "main422-10" if chroma == "422" else "main10"]
|
||
return args, pix
|
||
|
||
|
||
def _args_salida(fm, f):
|
||
"""Lo que no es imagen: el contenedor y el audio."""
|
||
# use_metadata_tags: sin ella el MP4 solo guarda las etiquetas que conoce y se pierden las de la cámara
|
||
# (ISO, balance de blancos, obturación, óptica, rollo y toma): medido el 5-oct-2026, 48 de 188
|
||
# conversiones de clips de Blackmagic habían perdido entre 24 y 36 etiquetas cada una.
|
||
contenedor = ["-movflags", "+faststart+use_metadata_tags" if fm["ext"] == ".mp4" else "use_metadata_tags"]
|
||
# …pero con ella también viajarían, como si fueran etiquetas, las marcas de formato del original
|
||
# («major_brand = XAVC» dentro de un archivo que ya no es XAVC): esas tres se quitan
|
||
contenedor += ["-metadata", "major_brand=", "-metadata", "minor_version=", "-metadata", "compatible_brands="]
|
||
if fm["codec"] == "prores":
|
||
audio = ["-c:a", "pcm_s16le"]
|
||
elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1:
|
||
audio = ["-c:a", "copy"]
|
||
else:
|
||
# con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4
|
||
audio = ["-c:a", "aac", "-b:a", "320k"]
|
||
return contenedor, audio
|
||
|
||
|
||
def _salida_ajena(t):
|
||
"""True si en la ruta de salida hay un archivo que no dejó este mismo trabajo: ese no se pisa. El que dejó
|
||
él (un intento que no pasó la verificación, y que «Reintentar» rehace) sí."""
|
||
salida = t["salida"]
|
||
if not os.path.exists(salida):
|
||
return False
|
||
try:
|
||
return not (t["bytes_out"] and os.path.getsize(salida) == t["bytes_out"])
|
||
except OSError:
|
||
return True
|
||
|
||
|
||
def _quitar_parcial(tmp):
|
||
"""Borra el archivo a medio escribir. Si ffmpeg aún lo tiene cogido (lo acaban de cortar), espera."""
|
||
for _ in range(40):
|
||
try:
|
||
if os.path.exists(tmp):
|
||
os.remove(tmp)
|
||
return
|
||
except OSError:
|
||
time.sleep(0.25)
|
||
|
||
|
||
# Los códecs que descomprime la tarjeta. ProRes, DNxHD y MJPEG no: esos los sigue leyendo el procesador.
|
||
NVDEC = {"h264", "hevc", "av1", "vp9", "mpeg2video"}
|
||
|
||
|
||
def _decodificar_con_tarjeta(f, motor):
|
||
"""["-hwaccel", "cuda"] si el clip lo puede descomprimir la tarjeta, o []. Hasta la 3.8 lo descomprimía el
|
||
procesador y la tarjeta esperaba: medido el 14-oct-2026 en la RTX 5080 del estudio, con la cola en marcha,
|
||
el codificador iba al 46-74 % y el descompresor de la tarjeta al 0 %. Con dos clips 4K de Vía Sumapaz, un
|
||
H.264 pasó de 0,54× a 0,86× tiempo real y un H.265 de 0,70× a 0,85×, con el mismo archivo de salida. Los
|
||
cuadros vuelven a la memoria (sin «-hwaccel_output_format cuda»): el formato de píxel y los filtros siguen
|
||
igual, y hacerlo todo en la tarjeta no ganó nada medible. Si con ella falla, se repite con el procesador."""
|
||
if motor != "gpu" or not f or (f["codec"] or "") not in NVDEC or _opt("sin_nvdec", False):
|
||
return []
|
||
return ["-hwaccel", "cuda"] if motores()["gpu"].get("nvdec") else []
|
||
|
||
|
||
def correr_ffmpeg(t):
|
||
con = db()
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
con.close()
|
||
dur = (f["dur"] if f else 0) or 0
|
||
fps = (f["fps"] if f else 30) or 30
|
||
formato = formato_de(t)
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
motor, pega = _motor_de(formato, t["chroma"] or "420", f["ancho"] if f else 0, f["alto"] if f else 0)
|
||
if not motor:
|
||
set_trabajo(t["id"], estado="error", log=pega)
|
||
return
|
||
salida = t["salida"]
|
||
if _salida_ajena(t):
|
||
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
|
||
return
|
||
os.makedirs(os.path.dirname(salida), exist_ok=True)
|
||
tmp = salida + ".parcial" + fm["ext"]
|
||
video, _ = _args_video(t, fm, motor, fps)
|
||
contenedor, audio = _args_salida(fm, f)
|
||
con_tarjeta = _decodificar_con_tarjeta(f, motor)
|
||
for entrada in ([con_tarjeta, []] if con_tarjeta else [[]]):
|
||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", *entrada, "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
|
||
cmd += video + ["-map_metadata", "0"] + contenedor + audio
|
||
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
|
||
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
|
||
try:
|
||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
|
||
except OSError as ex:
|
||
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}")
|
||
return
|
||
ultimo = 0
|
||
for linea in p.stdout:
|
||
if COLA["cancelar"]:
|
||
p.kill()
|
||
break
|
||
if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="):
|
||
try:
|
||
us = int(linea.split("=")[1]) # ffmpeg reporta microsegundos en las dos claves
|
||
pct = min(99.0, us / 1e6 / dur * 100) if dur else 0
|
||
if time.time() - ultimo > 1:
|
||
set_trabajo(t["id"], pct=pct)
|
||
ultimo = time.time()
|
||
except ValueError:
|
||
pass
|
||
err = p.stderr.read()
|
||
p.wait()
|
||
if COLA["cancelar"]:
|
||
set_trabajo(t["id"], estado="cancelado", pct=0)
|
||
_quitar_parcial(tmp)
|
||
return
|
||
if p.returncode == 0 and os.path.exists(tmp):
|
||
break
|
||
_quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
|
||
if not entrada:
|
||
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
|
||
return
|
||
# con la tarjeta descomprimiendo no salió: se repite con el procesador, como hasta la 3.8
|
||
os.replace(tmp, salida)
|
||
try:
|
||
st = os.stat(t["ruta"])
|
||
os.utime(salida, (st.st_atime, st.st_mtime))
|
||
except OSError:
|
||
pass
|
||
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time())
|
||
|
||
|
||
def correr_braw(t):
|
||
"""Un BRAW. Lo lee el decodificador de Blackmagic en un proceso aparte (braw.py), que le va pasando los
|
||
cuadros ya revelados a ffmpeg; el codificador y la calidad son los de cualquier otro clip. No hace
|
||
falta tener Resolve abierto, y si lo está no se le toca nada."""
|
||
formato = formato_de(t)
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
dec = decodificador_braw()
|
||
if not dec.get("ok"):
|
||
set_trabajo(t["id"], estado="error", log=dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||
return
|
||
salida = t["salida"]
|
||
if _salida_ajena(t):
|
||
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
|
||
return
|
||
m = medir_braw(t["ruta"])
|
||
if not m.get("ok"):
|
||
set_trabajo(t["id"], estado="error", log=m.get("error") or "el lector de BRAW no pudo abrir el clip")
|
||
return
|
||
motor, pega = _motor_de(formato, t["chroma"] or "420", m["ancho"], m["alto"])
|
||
if not motor:
|
||
set_trabajo(t["id"], estado="error", log=pega)
|
||
return
|
||
# la tasa se calcula con el tamaño de verdad del clip, que es el que dice el decodificador
|
||
kbps = int(kbps_objetivo(m["ancho"], m["alto"], m["fps"], t["perfil"]) * fm.get("bpp_mult", 1.0))
|
||
t = dict(t, kbps=kbps)
|
||
con = db()
|
||
with con:
|
||
con.execute("UPDATE archivos SET ancho = ?, alto = ?, fps = ?, dur = ?, perfil = ? WHERE ruta = ?",
|
||
(m["ancho"], m["alto"], m["fps"], m["dur"], _curva(m), t["ruta"]))
|
||
con.execute("UPDATE trabajos SET kbps = ? WHERE id = ?", (kbps, t["id"]))
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
con.close()
|
||
os.makedirs(os.path.dirname(salida), exist_ok=True)
|
||
tmp = salida + ".parcial" + fm["ext"]
|
||
video, pix = _args_video(t, fm, motor, m["fps"])
|
||
contenedor, audio = _args_salida(fm, f)
|
||
ficha = os.path.join(DATOS, f"braw_{t['id']}.json")
|
||
bandera = ficha + ".cancelar"
|
||
guardar_json(ficha, {"src": t["ruta"], "tmp": tmp, "ffmpeg": ruta_herramienta("ffmpeg"), "pix": pix, "video": video,
|
||
"contenedor": contenedor, "audio": audio, "color": t["color"] or "rec709", "cancelar": bandera,
|
||
"sdk": dec.get("carpeta") or ""})
|
||
try:
|
||
os.remove(bandera)
|
||
except OSError:
|
||
pass
|
||
errores = tempfile.TemporaryFile()
|
||
try:
|
||
p = subprocess.Popen([sys.executable, os.path.join(BASE, "braw.py"), "convertir", ficha], stdout=subprocess.PIPE, stderr=errores,
|
||
text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
|
||
except OSError as ex:
|
||
errores.close()
|
||
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}")
|
||
return
|
||
|
||
def vigia():
|
||
# Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se
|
||
# corta, y con él su ffmpeg (p.kill() solo cerraba el lector, y su ffmpeg seguía vivo)
|
||
while p.poll() is None:
|
||
if COLA["cancelar"]:
|
||
try:
|
||
open(bandera, "w").close()
|
||
p.wait(20)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
_matar_arbol(p.pid)
|
||
return
|
||
time.sleep(0.5)
|
||
threading.Thread(target=vigia, daemon=True).start()
|
||
fin, dicho = {}, ""
|
||
for linea in p.stdout:
|
||
try:
|
||
ev = json.loads(linea)
|
||
except ValueError:
|
||
continue
|
||
if ev.get("estado"):
|
||
fin = ev
|
||
elif "pct" in ev:
|
||
set_trabajo(t["id"], pct=ev["pct"])
|
||
elif ev.get("msg"):
|
||
dicho = ev["msg"]
|
||
set_trabajo(t["id"], log=dicho)
|
||
p.wait()
|
||
p.stdout.close()
|
||
try:
|
||
errores.seek(0)
|
||
cola_err = errores.read().decode("utf-8", "replace").strip()[-2000:]
|
||
except (OSError, ValueError):
|
||
cola_err = ""
|
||
errores.close()
|
||
for resto in (ficha, bandera):
|
||
try:
|
||
os.remove(resto)
|
||
except OSError:
|
||
pass
|
||
if COLA["cancelar"] or fin.get("estado") == "cancelado":
|
||
set_trabajo(t["id"], estado="cancelado", pct=0)
|
||
_quitar_parcial(tmp)
|
||
return
|
||
if fin.get("estado") != "listo" or not os.path.exists(tmp):
|
||
set_trabajo(t["id"], estado="error",
|
||
log=(fin.get("log") or cola_err or f"el lector de BRAW se cerró sin acabar el clip (código {p.returncode})")[-3000:])
|
||
_quitar_parcial(tmp)
|
||
return
|
||
os.replace(tmp, salida)
|
||
try:
|
||
st = os.stat(t["ruta"])
|
||
os.utime(salida, (st.st_atime, st.st_mtime))
|
||
except OSError:
|
||
pass
|
||
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time(),
|
||
log=(f"{dicho} · {fin.get('segundos', 0)} s · " + " ".join(video))[:4000])
|
||
|
||
|
||
def correr_resolve(trabajos):
|
||
"""Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte)."""
|
||
o = CONFIG["optimizar"]
|
||
con = db()
|
||
jobs = []
|
||
for t in trabajos:
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
jobs.append({"id": t["id"], "src": t["ruta"], "dst": t["salida"], "w": (f["ancho"] if f else 0) or 0,
|
||
"h": (f["alto"] if f else 0) or 0, "fps": (f["fps"] if f else 0) or 0, "kbps": t["kbps"],
|
||
"color": t["color"] or "rec709"})
|
||
set_trabajo(t["id"], estado="corriendo", pct=0)
|
||
con.close()
|
||
lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json")
|
||
with open(lote, "w", encoding="utf-8") as fh:
|
||
json.dump(jobs, fh, ensure_ascii=False)
|
||
# el puente corre con el mismo motor que el programa (en el paquete, su propio .exe haciendo de intérprete)
|
||
cmd = [o.get("python_resolve") or sys.executable, os.path.join(BASE, "resolve_bridge.py"), lote]
|
||
try:
|
||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
|
||
except OSError as ex:
|
||
for t in trabajos:
|
||
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el puente de Resolve: {ex}")
|
||
return
|
||
log_general = []
|
||
for linea in p.stdout:
|
||
linea = linea.strip()
|
||
if not linea:
|
||
continue
|
||
if linea.startswith("{"):
|
||
try:
|
||
ev = json.loads(linea)
|
||
except ValueError:
|
||
log_general.append(linea)
|
||
continue
|
||
if "id" in ev:
|
||
kw = {}
|
||
if "pct" in ev:
|
||
kw["pct"] = ev["pct"]
|
||
if ev.get("estado"):
|
||
kw["estado"] = ev["estado"]
|
||
if ev.get("log"):
|
||
kw["log"] = ev["log"][-3000:]
|
||
if ev.get("estado") == "listo":
|
||
kw["fin"] = time.time()
|
||
try:
|
||
kw["bytes_out"] = os.path.getsize(ev.get("salida") or "")
|
||
except OSError:
|
||
pass
|
||
set_trabajo(ev["id"], **kw)
|
||
else:
|
||
COLA["mensaje"] = ev.get("msg", "")
|
||
log_general.append(ev.get("msg", ""))
|
||
else:
|
||
log_general.append(linea)
|
||
if COLA["cancelar"]:
|
||
p.kill()
|
||
break
|
||
p.wait()
|
||
try:
|
||
os.remove(lote)
|
||
except OSError:
|
||
pass
|
||
con = db()
|
||
for t in trabajos:
|
||
r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
|
||
if r and r["estado"] == "corriendo":
|
||
set_trabajo(t["id"], estado="cancelado" if COLA["cancelar"] else "error",
|
||
log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo")
|
||
con.close()
|
||
|
||
|
||
def _verificar_listos(ids):
|
||
"""al acabar cada conversión se verifica sola (códec, 10 bits, duración): el usuario solo decide si reemplaza"""
|
||
try:
|
||
con = db()
|
||
listos = [r["id"] for r in con.execute(f"SELECT id FROM trabajos WHERE estado = 'listo' AND id IN ({','.join('?' * len(ids))})", ids).fetchall()] if ids else []
|
||
con.close()
|
||
if listos:
|
||
verificar(listos)
|
||
except Exception as ex: # noqa
|
||
COLA["mensaje"] = f"no se pudo verificar: {ex}"
|
||
|
||
|
||
def _clip_presente(t):
|
||
"""Justo antes de convertir: si el clip ya no está donde dice el trabajo, se busca en el inventario (se
|
||
movió). Si no aparece, el trabajo falla diciendo por qué y qué hacer, sin llamar a ffmpeg (antes salía su
|
||
«No such file or directory», en inglés y sin pista). Devuelve el trabajo al día, o None."""
|
||
if os.path.isfile(t["ruta"]):
|
||
return t
|
||
if reubicar_trabajos(ids=[t["id"]]):
|
||
con = db()
|
||
nuevo = con.execute("SELECT * FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
|
||
con.close()
|
||
if nuevo and nuevo["estado"] == "pendiente" and os.path.isfile(nuevo["ruta"]):
|
||
return dict(nuevo)
|
||
try:
|
||
donde = os.path.relpath(os.path.dirname(t["ruta"]), t["raiz"])
|
||
except ValueError:
|
||
donde = os.path.dirname(t["ruta"])
|
||
set_trabajo(t["id"], estado="error", log=f"el clip ya no está en «{donde}»: se movió o se borró fuera de OrganiLAB. "
|
||
"Vuelve a escanear la carpeta: la cola lo buscará en su sitio nuevo y lo convertirá allí.")
|
||
return None
|
||
|
||
|
||
def simultaneas():
|
||
"""Cuántos clips convierte la cola a la vez: optimizar.simultaneas, o, si es 0, dos con tarjeta NVIDIA y uno
|
||
sin ella. Medido el 14-oct-2026 en la RTX 5080 del estudio, que tiene dos codificadores: con un clip a la vez
|
||
el codificador iba al 46-74 % y la tarjeta entera al 9-18 %; dos clips 4K a la vez salen a 1,29× tiempo real
|
||
entre los dos, frente a unos 0,77× de uno solo."""
|
||
try:
|
||
n = int(_opt("simultaneas", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
n = 0
|
||
if n <= 0:
|
||
g = motores()["gpu"]
|
||
n = 2 if (g.get("hevc") or g.get("h264")) else 1
|
||
return max(1, min(3, n))
|
||
|
||
|
||
_COLA_TOMA = threading.Lock() # para que dos trabajadores no cojan el mismo clip
|
||
|
||
|
||
def _fijar_actual():
|
||
"""«actual» es el primero de los que van (lo que enseñan la barra de abajo y la de Windows); «actuales», todos."""
|
||
ids = sorted(COLA["actuales"])
|
||
COLA["actual"] = ids[0] if ids else None
|
||
COLA["empezo"] = COLA["actuales"].get(ids[0], 0) if ids else 0
|
||
|
||
|
||
def _soltar(*ids):
|
||
with _COLA_TOMA:
|
||
for i in ids:
|
||
COLA["actuales"].pop(i, None)
|
||
_fijar_actual()
|
||
|
||
|
||
def _tomar(n):
|
||
"""El siguiente clip para el trabajador n, ya apartado para que no lo coja otro. Los BRAW que van por el guion
|
||
de Resolve (un lote de hasta 40) solo los coge el primero, y solo cuando no queda nada más."""
|
||
with _COLA_TOMA:
|
||
ocupados = list(COLA["actuales"])
|
||
filtro = f" AND id NOT IN ({','.join('?' * len(ocupados))})" if ocupados else ""
|
||
con = db()
|
||
try:
|
||
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw')" + filtro +
|
||
" ORDER BY orden DESC, id LIMIT 1", ocupados).fetchone()
|
||
lote = [] if t or n else [dict(r) for r in con.execute(
|
||
"SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve'" + filtro + " ORDER BY id LIMIT 40", ocupados).fetchall()]
|
||
finally:
|
||
con.close()
|
||
for i in ([t["id"]] if t else [r["id"] for r in lote]):
|
||
COLA["actuales"][i] = time.time()
|
||
_fijar_actual()
|
||
return (dict(t) if t else None), lote
|
||
|
||
|
||
def _ya_no_compensa(t):
|
||
"""Una conversión en espera que con la regla de ahorro de ahora no se ofrecería (se encoló con la de antes): no
|
||
se convierte. Solo en los formatos que existen para pesar menos. Devuelve el porqué, o «»."""
|
||
if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc" or t["motor"] != "ffmpeg":
|
||
return ""
|
||
con = db()
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
con.close()
|
||
if not f or not f["dur"] or not f["kbps"]:
|
||
return ""
|
||
gana = round((1 - estimar_bytes(f, t["perfil"], formato_de(t)) / (f["kbps"] * 1000 / 8 * f["dur"])) * 100)
|
||
ahorro = ahorro_minimo()
|
||
return f"convertido ahorraría solo un {max(gana, 0)} %, y se convierte desde un {ahorro} %" if gana < ahorro else ""
|
||
|
||
|
||
def _trabajador(n):
|
||
"""Uno de los que convierten a la vez: coge el siguiente clip, lo convierte y vuelve a por otro. Si no queda
|
||
nada pero otro aún trabaja, espera un poco por si se encola algo más (así no se pierde el «a la vez»)."""
|
||
while COLA["activa"] and not COLA["cancelar"]:
|
||
t, lote = _tomar(n)
|
||
if not t and not lote:
|
||
if any(COLA["actuales"]):
|
||
time.sleep(3)
|
||
continue
|
||
return
|
||
ids = [t["id"]] if t else [r["id"] for r in lote]
|
||
if not _esperar_turno():
|
||
_soltar(*ids)
|
||
return
|
||
if t:
|
||
t = _clip_presente(t)
|
||
pega = _ya_no_compensa(t) if t else ""
|
||
if pega:
|
||
con = db()
|
||
with con:
|
||
con.execute("DELETE FROM trabajos WHERE id = ? AND estado = 'pendiente'", (t["id"],))
|
||
con.close()
|
||
COLA["saltados"] += 1
|
||
COLA["mensaje"] = f"{COLA['saltados']} sin convertir: con la regla de ahorro de ahora ya no compensaban ({pega})"
|
||
if not t or pega:
|
||
_soltar(*ids)
|
||
continue
|
||
COLA["actuales"][t["id"]] = empezo = time.time() # el tiempo cuenta desde que empieza de verdad
|
||
_fijar_actual()
|
||
set_trabajo(t["id"], estado="corriendo", pct=0)
|
||
try:
|
||
(correr_braw if t["motor"] == "braw" else correr_ffmpeg)(t)
|
||
except Exception as ex: # noqa
|
||
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
|
||
_verificar_listos([t["id"]])
|
||
_anotar_ritmo(t["id"], time.time() - empezo)
|
||
if CONFIG["optimizar"].get("auto_reemplazar"):
|
||
try: # solo reemplaza lo verificado; lo demás lo deja como está
|
||
reemplazar([t["id"]])
|
||
except Exception as ex: # noqa
|
||
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
|
||
hechos = [t["id"]]
|
||
else:
|
||
lote = [x for x in map(_clip_presente, lote) if x]
|
||
try:
|
||
if lote:
|
||
correr_resolve(lote)
|
||
except Exception as ex: # noqa
|
||
for r in lote:
|
||
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
|
||
_verificar_listos([r["id"] for r in lote])
|
||
hechos = [r["id"] for r in lote]
|
||
with _COLA_TOMA:
|
||
COLA["hechos"] += len(hechos)
|
||
COLA["hechos_ids"].extend(hechos)
|
||
_soltar(*ids)
|
||
|
||
|
||
def bucle_cola():
|
||
"""La cola: arranca los trabajadores (ver simultaneas) y espera a que acaben todos."""
|
||
try:
|
||
hilos = [threading.Thread(target=_trabajador, args=(i,), daemon=True) for i in range(simultaneas())]
|
||
for h in hilos:
|
||
h.start()
|
||
for h in hilos:
|
||
h.join()
|
||
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["cancelar"] = False
|
||
COLA["actuales"] = {}
|
||
COLA["actual"] = None
|
||
COLA["hilo"] = None
|
||
COLA["esperando"] = ""
|
||
try:
|
||
_resumen_tanda()
|
||
except Exception as ex: # noqa
|
||
COLA["mensaje"] = f"no se pudo apuntar el resumen de la tanda: {ex}"
|
||
COLA["hechos_ids"] = []
|
||
|
||
|
||
def _anotar_ritmo(id_, segundos):
|
||
"""Cuánto clip salió por segundo real en este trabajo, si acabó bien: con eso se calcula cuánto falta."""
|
||
try:
|
||
con = db()
|
||
t = con.execute("SELECT estado, ruta FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||
con.close()
|
||
if t and t["estado"] in ("listo", "verificado", "reemplazado", "descartado") and f and (f["dur"] or 0) > 0 and segundos > 0:
|
||
COLA["ritmo"].append((float(f["dur"]), float(segundos)))
|
||
except Exception: # noqa
|
||
pass
|
||
|
||
|
||
def eta_cola(trabajos=None):
|
||
"""Cuánto falta para que acabe la cola: segundos de clip pendientes partidos por el ritmo medido (clips ya
|
||
hechos en esta tanda; si no hay ninguno, el avance de los que van; si tampoco, a tiempo real los BRAW y a 2,5×
|
||
la tarjeta), por cuántos van a la vez. Sin lista, mira toda la cola: hasta la 3.8 contaba solo los 500 últimos
|
||
trabajos, y con 1.920 en espera en Vía Sumapaz decía que acababa en 7 horas y no en 26. Devuelve también los
|
||
clips que van, con lo que le queda a cada uno."""
|
||
con = db()
|
||
if trabajos is None:
|
||
pend = [dict(r) for r in con.execute("SELECT t.id, t.estado, t.pct, t.motor, a.dur AS dur FROM trabajos t "
|
||
"LEFT JOIN archivos a ON a.ruta = t.ruta WHERE t.estado IN ('pendiente','corriendo')")]
|
||
else:
|
||
pend = [dict(t) for t in trabajos if t["estado"] in ("pendiente", "corriendo")]
|
||
for t in pend:
|
||
if t.get("dur") is None:
|
||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
t["dur"] = f["dur"] if f else 0
|
||
con.close()
|
||
if not pend:
|
||
return None
|
||
ritmo = COLA.get("ritmo") or []
|
||
clip_s, real_s = sum(d for d, _ in ritmo), sum(s for _, s in ritmo)
|
||
vel, medido = (clip_s / real_s if clip_s > 0 and real_s > 0 else 0.0), "tanda" if ritmo else ""
|
||
falta, actuales, ahora = 0.0, [], time.time()
|
||
for t in pend:
|
||
d = float(t.get("dur") or 0)
|
||
if t["estado"] == "corriendo":
|
||
pct = float(t["pct"] or 0)
|
||
resto = d * max(0.0, 1 - pct / 100)
|
||
falta += resto
|
||
llevado = ahora - (COLA["actuales"].get(t["id"]) or COLA.get("empezo") or ahora)
|
||
if not vel and pct >= 3 and llevado > 5:
|
||
vel, medido = (d * pct / 100) / llevado, "clip"
|
||
actuales.append({"id": t["id"], "dur": d, "resto": resto, "llevado": llevado})
|
||
else:
|
||
falta += d
|
||
if not vel:
|
||
vel = 1.0 if any(t["motor"] == "braw" for t in pend) else 2.5
|
||
paralelo = max(1, min(simultaneas() if COLA["activa"] else 1, len(pend)))
|
||
seg = falta / (vel * paralelo) if falta else 0.0
|
||
for a in actuales:
|
||
a["resto_seg"] = a["resto"] / vel
|
||
return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel * paralelo, 2), "vel_clip": round(vel, 2), "paralelo": paralelo,
|
||
"medido": medido, "termina": ahora + seg if seg else 0, "actual": actuales[0] if actuales else None, "actuales": actuales,
|
||
"en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)}
|
||
|
||
|
||
def _ruta_original(t):
|
||
"""Dónde está hoy el original de un trabajo: en su sitio o, si ya se reemplazó, en _Para borrar/originales."""
|
||
if os.path.isfile(t["ruta"]):
|
||
return t["ruta"]
|
||
try:
|
||
otra = os.path.join(t["raiz"], R.PAPELERA, "originales", os.path.relpath(t["ruta"], t["raiz"]))
|
||
except ValueError:
|
||
return ""
|
||
return otra if os.path.isfile(otra) else ""
|
||
|
||
|
||
def cuadro_de(id_, cual, segundo, ancho=1600):
|
||
"""Un cuadro (JPEG) del original o del convertido de un trabajo, en el segundo pedido; se guarda en
|
||
datos/cuadros para no sacarlo dos veces. Devuelve la ruta del JPEG o lanza ValueError."""
|
||
con = db()
|
||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||
con.close()
|
||
if not t:
|
||
raise ValueError("no existe ese trabajo")
|
||
segundo = max(0.0, float(segundo or 0))
|
||
carpeta = os.path.join(DATOS, "cuadros")
|
||
os.makedirs(carpeta, exist_ok=True)
|
||
salida = os.path.join(carpeta, f"{id_}_{'o' if cual == 'original' else 'c'}_{int(round(segundo * 10))}.jpg")
|
||
if os.path.isfile(salida) and os.path.getsize(salida) > 0:
|
||
return salida
|
||
if cual == "original":
|
||
src = _ruta_original(dict(t))
|
||
if not src:
|
||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||
else:
|
||
src = t["salida"]
|
||
if not os.path.isfile(src):
|
||
raise ValueError("el convertido ya no está donde se hizo")
|
||
if cual == "original" and t["motor"] == "braw":
|
||
dec = decodificador_braw()
|
||
if not dec.get("ok"):
|
||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||
ficha = os.path.join(DATOS, f"cuadro_{id_}.json")
|
||
guardar_json(ficha, {"src": src, "segundo": segundo, "salida": salida, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho})
|
||
try:
|
||
ev = [e for e in _lector_braw("cuadro", ficha, espera=180) if e.get("estado")]
|
||
finally:
|
||
try:
|
||
os.remove(ficha)
|
||
except OSError:
|
||
pass
|
||
if not ev or ev[-1].get("estado") != "listo":
|
||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo sacar el cuadro")
|
||
else:
|
||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{segundo:.3f}", "-i", src,
|
||
"-frames:v", "1", "-vf", f"scale={ancho}:-2:flags=bicubic,format=yuvj420p", "-q:v", "2", "-f", "image2", salida]
|
||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, creationflags=SIN_VENTANA)
|
||
if r.returncode != 0 or not os.path.isfile(salida):
|
||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo sacar el cuadro")
|
||
return salida
|
||
|
||
|
||
def calidad_de(id_, segundos=10, ancho=1920):
|
||
"""Nota de calidad (VMAF, 0 a 100) del convertido de un trabajo contra su original, sobre un tramo del medio
|
||
del clip. Se guarda en el trabajo (nota, nota_min). Lanza ValueError si no se puede."""
|
||
con = db()
|
||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||
con.close()
|
||
if not t:
|
||
raise ValueError("no existe ese trabajo")
|
||
src = _ruta_original(dict(t))
|
||
if not src:
|
||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||
if not os.path.isfile(t["salida"]):
|
||
raise ValueError("el convertido ya no está donde se hizo")
|
||
fps = float((f["fps"] if f else 0) or 0) or 25.0
|
||
dur = float((f["dur"] if f else 0) or 0)
|
||
total = int(dur * fps) if dur else 0
|
||
n = max(24, min(int(segundos * fps), 300, total or 300))
|
||
desde = max(0, (total - n) // 2) if total else 0
|
||
hilos = max(2, min(16, (os.cpu_count() or 8) - 2))
|
||
carpeta = os.path.join(DATOS, "cuadros")
|
||
os.makedirs(carpeta, exist_ok=True)
|
||
log = f"calidad_{id_}.json"
|
||
t0 = time.time()
|
||
if t["motor"] == "braw":
|
||
dec = decodificador_braw()
|
||
if not dec.get("ok"):
|
||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||
ficha = os.path.join(DATOS, f"calidad_{id_}.ficha.json")
|
||
guardar_json(ficha, {"src": src, "conv": t["salida"], "desde": desde, "cuadros": n, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho, "log": log, "cwd": carpeta, "vmaf_hilos": hilos})
|
||
try:
|
||
ev = [e for e in _lector_braw("calidad", ficha, espera=900) if e.get("estado")]
|
||
finally:
|
||
try:
|
||
os.remove(ficha)
|
||
except OSError:
|
||
pass
|
||
if not ev or ev[-1].get("estado") != "listo":
|
||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo medir")
|
||
nota, minimo, cuadros = ev[-1]["vmaf"], ev[-1]["minimo"], ev[-1]["medidos"]
|
||
else:
|
||
fc = (f"[0:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r];"
|
||
f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d];"
|
||
f"[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={log}")
|
||
ss = f"{desde / fps:.6f}"
|
||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", ss, "-i", src, "-ss", ss, "-i", t["salida"],
|
||
"-filter_complex", fc, "-frames:v", str(n), "-an", "-f", "null", "-"]
|
||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, cwd=carpeta, creationflags=SIN_VENTANA)
|
||
ruta_log = os.path.join(carpeta, log)
|
||
if r.returncode != 0 or not os.path.isfile(ruta_log):
|
||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo medir (¿este ffmpeg trae libvmaf?)")
|
||
with open(ruta_log, "r", encoding="utf-8") as fh:
|
||
j = json.load(fh)
|
||
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
|
||
nota, minimo, cuadros = round(float(v.get("mean") or 0), 1), round(float(v.get("min") or 0), 1), len(j.get("frames") or [])
|
||
try:
|
||
os.remove(os.path.join(carpeta, log))
|
||
except OSError:
|
||
pass
|
||
set_trabajo(id_, nota=nota, nota_min=minimo)
|
||
return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)}
|
||
|
||
|
||
def _matar_arbol(pid):
|
||
"""Cierra un proceso y lo que lanzó (el lector de BRAW lanza su propio ffmpeg)."""
|
||
try:
|
||
if os.name == "nt":
|
||
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, timeout=30, creationflags=SIN_VENTANA)
|
||
else:
|
||
os.kill(pid, 9)
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
|
||
|
||
def _conversores_sueltos(procesos, base, yo):
|
||
"""De una lista de procesos ({ProcessId, ParentProcessId, Name, CommandLine}), los de conversión de esta
|
||
instalación (un ffmpeg o un lector de BRAW) que ya no tienen quien los espere: su padre murió, o el número
|
||
lo reusó otro proceso. Los de un programa vivo (este, u otra instancia como la pasada de la noche) no."""
|
||
vivos = {p.get("ProcessId"): p for p in procesos}
|
||
base = base.lower()
|
||
|
||
def conversor(p):
|
||
cmd = (p.get("CommandLine") or "").lower()
|
||
return base in cmd and ((p.get("Name") or "").lower() == "ffmpeg.exe" or "braw.py" in cmd)
|
||
|
||
def programa(p):
|
||
cmd = (p.get("CommandLine") or "").lower()
|
||
return p.get("ProcessId") == yo or (not conversor(p) and (base in cmd or "organizador" in cmd or "ventana.py" in cmd))
|
||
sueltos = []
|
||
for p in procesos:
|
||
if not conversor(p):
|
||
continue
|
||
padre = vivos.get(p.get("ParentProcessId"))
|
||
if padre and (conversor(padre) or programa(padre)):
|
||
continue # el ffmpeg de un lector va con su lector; lo demás lo espera un programa vivo
|
||
sueltos.append(p)
|
||
return sueltos
|
||
|
||
|
||
def cerrar_conversores_sueltos():
|
||
"""Al arrancar, se cierran las conversiones que quedaron vivas sin el programa que las lanzó, y se borra lo que
|
||
dejaron a medio escribir. Medido el 14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de las 10:04,
|
||
de antes de actualizar a la 3.8.0, seguían a las 17:00 colgados, con una sesión del codificador de la tarjeta
|
||
y su memoria, y su «.parcial.mp4» en la carpeta de Galeano."""
|
||
if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"):
|
||
return []
|
||
filtro = "Name='ffmpeg.exe' or Name='OrganizadorLabstream.exe' or Name like 'python%'"
|
||
try:
|
||
r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||
f'Get-CimInstance Win32_Process -Filter "{filtro}" | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'],
|
||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, creationflags=SIN_VENTANA)
|
||
procesos = json.loads(r.stdout or "[]")
|
||
except (OSError, subprocess.SubprocessError, ValueError):
|
||
return []
|
||
if isinstance(procesos, dict):
|
||
procesos = [procesos]
|
||
sueltos = _conversores_sueltos(procesos, BASE, os.getpid())
|
||
if not sueltos:
|
||
return []
|
||
cerrados = {p["ProcessId"] for p in sueltos}
|
||
for p in sueltos:
|
||
_matar_arbol(p["ProcessId"])
|
||
# lo que dejaron a medias: el último argumento de cada ffmpeg cerrado (el suyo o el de su lector)
|
||
parciales = []
|
||
for p in procesos:
|
||
if (p.get("Name") or "").lower() == "ffmpeg.exe" and (p.get("ProcessId") in cerrados or p.get("ParentProcessId") in cerrados):
|
||
m = re.search(r'(?:"([^"]*\.parcial\.\w+)"|(\S*\.parcial\.\w+))\s*$', p.get("CommandLine") or "")
|
||
if m:
|
||
parciales.append(m.group(1) or m.group(2))
|
||
for tmp in parciales:
|
||
_quitar_parcial(tmp)
|
||
bitacora({"op": "conversores_sueltos", "cerrados": sorted(cerrados), "parciales": parciales})
|
||
return sorted(cerrados)
|
||
|
||
|
||
def recuperar_huerfanas():
|
||
"""Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de
|
||
una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso
|
||
Convertir decía «nada» (visto el 2026-09-15: 5 clips de V:\\Galeano al 18-89 % desde el día 10).
|
||
Vuelven a «pendiente» con el motivo en el log; Reanudar los convierte desde cero."""
|
||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||
return 0
|
||
con = db()
|
||
with con:
|
||
filas = con.execute("SELECT id, pct FROM trabajos WHERE estado = 'corriendo'").fetchall()
|
||
for f in filas:
|
||
con.execute("UPDATE trabajos SET estado = 'pendiente', pct = 0, log = ? WHERE id = ?",
|
||
(f"se interrumpió al cerrar OrganiLAB (iba al {round(f['pct'] or 0)} %); pulsa Reanudar para convertirlo de nuevo", f["id"]))
|
||
con.close()
|
||
return len(filas)
|
||
|
||
|
||
def braws_al_lector():
|
||
"""Las conversiones de BRAW que quedaron en espera o fallidas por la vía de Resolve pasan al lector
|
||
directo. De cada clip se queda una sola, la última; las que apuntan a un clip que ya no está en esa ruta
|
||
—la carpeta se reordenó después— se quitan, porque reintentarlas no podía salir bien."""
|
||
if not decodificador_braw().get("ok"):
|
||
return 0
|
||
con = db()
|
||
n = 0
|
||
with con:
|
||
vistos = set()
|
||
for t in con.execute("SELECT id, ruta, raiz, formato FROM trabajos WHERE motor = 'resolve' "
|
||
"AND estado IN ('pendiente','error','cancelado') ORDER BY id DESC").fetchall():
|
||
if not os.path.isdir(t["raiz"] or ""):
|
||
continue # unidad desconectada: no se sabe si el clip sigue ahí
|
||
clave = (os.path.normcase(t["ruta"]), t["formato"] or "h265_10bit")
|
||
if clave in vistos or not os.path.isfile(t["ruta"]):
|
||
con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],))
|
||
else:
|
||
vistos.add(clave)
|
||
con.execute("UPDATE trabajos SET motor = 'braw' WHERE id = ?", (t["id"],))
|
||
con.execute("UPDATE trabajos SET log = ? WHERE id = ? AND estado = 'error'",
|
||
("no salió por la vía de Resolve; ahora se lee sin Resolve: pulsa «Reintentar los que fallaron»", t["id"]))
|
||
n += 1
|
||
con.close()
|
||
return n
|
||
|
||
|
||
class ErrorCola(ValueError):
|
||
"""Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto."""
|
||
def __init__(self, mensaje, datos):
|
||
super().__init__(mensaje)
|
||
self.datos = datos
|
||
|
||
|
||
_COLA_LOCK = threading.Lock()
|
||
|
||
|
||
def iniciar_cola():
|
||
# el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que
|
||
# alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal.
|
||
with _COLA_LOCK:
|
||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||
return False
|
||
recuperar_huerfanas()
|
||
try:
|
||
braws_al_lector()
|
||
except Exception: # noqa
|
||
pass
|
||
COLA["activa"] = True
|
||
COLA["cancelar"] = False
|
||
COLA["hechos"] = 0
|
||
COLA["inicio"] = time.time()
|
||
COLA["ritmo"] = []
|
||
COLA["hechos_ids"] = []
|
||
COLA["esperando"] = ""
|
||
COLA["actuales"] = {}
|
||
COLA["saltados"] = 0
|
||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||
COLA["hilo"].start()
|
||
return True
|
||
|
||
|
||
def _no_compensa(t, sale=None):
|
||
"""Por qué no merece la pena dar por buena esta conversión, o «» si sí. Solo cuenta para los formatos que
|
||
existen para pesar menos (H.265). El 5-oct-2026 había 8 conversiones verificadas esperando en la cola de
|
||
V:\\Galeano con el botón «Reemplazar 8 verificados»: seis eran reconversiones de un clip ya convertido
|
||
y pesaban entre el 85 y el 99 % de él. Reemplazar habría cambiado cada uno por una copia de su copia."""
|
||
if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc":
|
||
return ""
|
||
entra, sale = t["bytes_in"] or 0, sale or t["bytes_out"] or 0
|
||
if not entra or not sale:
|
||
return ""
|
||
repetida = R.es_conversion(os.path.basename(t["ruta"]))
|
||
if not repetida and sale <= (1 - max(5, ahorro_minimo() - 10) / 100) * entra:
|
||
return ""
|
||
return (f"no compensa: pesa el {round(sale / entra * 100)} % del clip del que salió"
|
||
+ (", que ya era una conversión de este programa" if repetida else "") + ". Se queda el que estaba.")
|
||
|
||
|
||
DESCARTADOS = "descartados"
|
||
|
||
|
||
def _apartar_convertido(t, lote, movidos):
|
||
"""Lo que salió de una conversión que no se usa va a _Para borrar\\descartados, con su ruta: no se borra, y
|
||
«Deshacer» lo devuelve. Solo si sigue siendo el archivo que dejó este trabajo. Devuelve dónde quedó."""
|
||
salida, raiz = t["salida"] or "", t["raiz"] or ""
|
||
if not os.path.isfile(salida) or not raiz or not dentro_de(raiz, salida):
|
||
return ""
|
||
if t["bytes_out"] and os.path.getsize(salida) != t["bytes_out"]:
|
||
return ""
|
||
antes = len(movidos)
|
||
mover(salida, os.path.join(raiz, R.PAPELERA, DESCARTADOS, os.path.relpath(salida, raiz)), lote, movidos)
|
||
return movidos[-1][1] if len(movidos) > antes else ""
|
||
|
||
|
||
def _cerrar_lote(lote, raiz, movidos, errores):
|
||
if movidos or errores:
|
||
actualizar_inventario(movidos)
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos), "movimientos": len(movidos), "errores": errores})
|
||
|
||
|
||
def _descartar(t, pega, lote, movidos, errores, **kw):
|
||
"""Marca el trabajo como «no compensa» y aparta lo que salió de él."""
|
||
nota = ""
|
||
try:
|
||
if _apartar_convertido(t, lote, movidos):
|
||
nota = f" Lo convertido quedó en «{R.PAPELERA}\\{DESCARTADOS}»."
|
||
except OSError as ex:
|
||
errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}")
|
||
nota = " Lo convertido sigue junto al original (no se pudo apartar: ¿está abierto?)."
|
||
set_trabajo(t["id"], estado="descartado", log=pega + nota, **kw)
|
||
return pega + nota
|
||
|
||
|
||
def apartar_descartados(ids):
|
||
"""«Limpiar terminados»: lo que salió de las conversiones que no compensaron y sigue junto a su original se
|
||
aparta antes de quitarlas de la cola. Devuelve también las que no se pudieron apartar: esas se quedan."""
|
||
con = db()
|
||
filas = [dict(r) for r in con.execute(f"SELECT * FROM trabajos WHERE estado = 'descartado' AND id IN ({','.join('?' * len(ids))})",
|
||
list(ids)).fetchall()] if ids else []
|
||
con.close()
|
||
lote, movidos, errores, raiz, quedan = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "", []
|
||
for t in filas:
|
||
try:
|
||
if _apartar_convertido(t, lote, movidos):
|
||
raiz = raiz or t["raiz"]
|
||
except OSError as ex:
|
||
errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}")
|
||
quedan.append(t["id"])
|
||
_cerrar_lote(lote, raiz or (filas[0]["raiz"] if filas else ""), movidos, errores)
|
||
return {"movidos": len(movidos), "errores": errores, "quedan": quedan}
|
||
|
||
|
||
def repasar_verificados():
|
||
"""Las conversiones verificadas con una versión anterior que no compensan dejan de ofrecerse para reemplazar.
|
||
Al arrancar solo cambia su estado: lo que salió de ellas se aparta cuando alguien limpia la cola o vuelve a
|
||
convertir el clip, no sin que nadie lo pida."""
|
||
con = db()
|
||
n = 0
|
||
for t in con.execute("SELECT * FROM trabajos WHERE estado = 'verificado'").fetchall():
|
||
pega = _no_compensa(t)
|
||
if pega:
|
||
with con:
|
||
con.execute("UPDATE trabajos SET estado = 'descartado', log = ? WHERE id = ?", (pega, t["id"]))
|
||
n += 1
|
||
con.close()
|
||
return n
|
||
|
||
|
||
def verificar(ids):
|
||
con = db()
|
||
res = []
|
||
lote, movidos, errores, raiz_lote = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
|
||
for id_ in ids:
|
||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
if not t or not os.path.exists(t["salida"]):
|
||
res.append({"id": id_, "ok": False, "motivo": "no existe la salida"})
|
||
continue
|
||
f = con.execute("SELECT dur, ancho, alto FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
info = ffprobe(t["salida"])
|
||
if not info:
|
||
res.append({"id": id_, "ok": False, "motivo": "ffprobe no pudo leer la salida"})
|
||
set_trabajo(id_, estado="error", log="verificación: ffprobe no pudo leer la salida")
|
||
continue
|
||
dur_src = (f["dur"] if f else 0) or 0
|
||
tol = max(0.5, dur_src * 0.01)
|
||
problemas = []
|
||
if dur_src and abs(info["dur"] - dur_src) > tol:
|
||
problemas.append(f"duración {info['dur']:.2f}s vs original {dur_src:.2f}s")
|
||
fm = FORMATOS.get((t["formato"] if "formato" in t.keys() else None) or "h265_10bit", FORMATOS["h265_10bit"])
|
||
if info["codec"] != fm["codec"]:
|
||
problemas.append(f"códec {info['codec']} (se esperaba {fm['codec']})")
|
||
if fm["bits"] == 10 and "10" not in info["pix_fmt"]:
|
||
problemas.append(f"no es 10 bits ({info['pix_fmt']})")
|
||
if t["motor"] == "braw" and f and f["ancho"] and (info["ancho"], info["alto"]) != (f["ancho"], f["alto"]):
|
||
problemas.append(f"imagen de {info['ancho']}×{info['alto']} (el clip es de {f['ancho']}×{f['alto']})")
|
||
if os.path.getsize(t["salida"]) < 100000:
|
||
problemas.append("archivo demasiado pequeño")
|
||
if problemas:
|
||
set_trabajo(id_, estado="error", log="verificación: " + "; ".join(problemas))
|
||
res.append({"id": id_, "ok": False, "motivo": "; ".join(problemas)})
|
||
else:
|
||
sale = os.path.getsize(t["salida"])
|
||
pega = _no_compensa(t, sale)
|
||
if pega: # está bien hecha, pero no merece ocupar el sitio del original
|
||
raiz_lote = raiz_lote or t["raiz"]
|
||
res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t, bytes_out=sale), pega, lote, movidos, errores, bytes_out=sale)})
|
||
else:
|
||
set_trabajo(id_, estado="verificado", bytes_out=sale)
|
||
res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"})
|
||
con.close()
|
||
_cerrar_lote(lote, raiz_lote, movidos, errores)
|
||
return res
|
||
|
||
|
||
def reemplazar(ids):
|
||
"""El original se va a _Para borrar/originales (misma raíz). Nunca se borra. Si el convertido quedó en otra
|
||
carpeta que su original (se movió uno y no el otro), antes se lleva junto a él: el convertido ocupa el
|
||
sitio del original, que es lo que promete reemplazar."""
|
||
reubicar_trabajos(ids=ids)
|
||
con = db()
|
||
res = []
|
||
lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia
|
||
movimientos, raiz_lote = 0, ""
|
||
# lo que no compensa va en su propio lote: deshacer el reemplazo no debe devolver también eso
|
||
lote_desc, movidos_desc, errores_desc, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
|
||
for id_ in ids:
|
||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
if not t or t["estado"] != "verificado":
|
||
res.append({"id": id_, "ok": False, "motivo": "solo se reemplazan trabajos verificados"})
|
||
continue
|
||
if ((t["formato"] if "formato" in t.keys() else "") or "").startswith("prores"):
|
||
res.append({"id": id_, "ok": False, "motivo": "ProRes es un formato de edición: queda junto al original, no lo reemplaza"})
|
||
continue
|
||
pega = _no_compensa(t)
|
||
if pega:
|
||
raiz_desc = raiz_desc or t["raiz"]
|
||
res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t), pega, lote_desc, movidos_desc, errores_desc)})
|
||
continue
|
||
if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024:
|
||
res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"})
|
||
set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar")
|
||
continue
|
||
raiz = t["raiz"]
|
||
if not os.path.isfile(t["ruta"]):
|
||
res.append({"id": id_, "ok": False, "motivo": "el original ya no está donde se convirtió; vuelve a escanear la carpeta y la cola lo encontrará"})
|
||
continue
|
||
if (CONFIG["optimizar"].get("salida") != "espejo"
|
||
and os.path.dirname(t["salida"]).lower() != os.path.dirname(t["ruta"]).lower()):
|
||
junto = os.path.join(os.path.dirname(t["ruta"]), os.path.basename(t["salida"]))
|
||
if os.path.exists(junto):
|
||
res.append({"id": id_, "ok": False, "motivo": f"el convertido está en otra carpeta y junto al original ya hay un «{os.path.basename(junto)}»"})
|
||
continue
|
||
try:
|
||
_renombrar(t["salida"], junto)
|
||
except OSError as ex:
|
||
res.append({"id": id_, "ok": False, "motivo": f"el convertido está en otra carpeta y no se pudo llevar junto al original: {ex}"})
|
||
continue
|
||
movimientos += 1
|
||
bitacora({"lote": lote, "op": "mover", "de": t["salida"], "a": junto, "trabajo": id_})
|
||
with con:
|
||
con.execute("UPDATE archivos SET ruta = ?, dir = ? WHERE ruta = ?", (junto, os.path.dirname(junto), t["salida"]))
|
||
set_trabajo(id_, salida=junto)
|
||
t = dict(t)
|
||
t["salida"] = junto
|
||
raiz_lote = raiz_lote or raiz
|
||
rel = os.path.relpath(t["ruta"], raiz)
|
||
destino = os.path.join(raiz, R.PAPELERA, "originales", rel)
|
||
try:
|
||
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
||
destino = nombre_libre(destino)
|
||
os.rename(t["ruta"], destino)
|
||
movimientos += 1
|
||
bitacora({"lote": lote, "op": "mover", "de": t["ruta"], "a": destino, "trabajo": id_})
|
||
# el sidecar .xml de Sony y el .sidecar de un BRAW (los ajustes que le dejó Resolve) se van con el original
|
||
base, ext = os.path.splitext(t["ruta"])
|
||
for extra in (base + "M01.xml", base + "M01.XML", base + ".sidecar"):
|
||
if os.path.exists(extra):
|
||
d2 = os.path.join(os.path.dirname(destino), os.path.basename(extra))
|
||
os.rename(extra, d2)
|
||
movimientos += 1
|
||
bitacora({"lote": lote, "op": "mover", "de": extra, "a": d2, "trabajo": id_})
|
||
set_trabajo(id_, estado="reemplazado")
|
||
with con:
|
||
con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = 1 WHERE ruta = ?", (destino, os.path.dirname(destino), t["ruta"]))
|
||
res.append({"id": id_, "ok": True, "motivo": f"original en {os.path.relpath(destino, raiz)}"})
|
||
except OSError as ex:
|
||
res.append({"id": id_, "ok": False, "motivo": str(ex)})
|
||
con.close()
|
||
if movimientos:
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz_lote, "filas": len(ids), "movimientos": movimientos,
|
||
"errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"] and not r["motivo"].startswith("no compensa")]})
|
||
_cerrar_lote(lote_desc, raiz_desc, movidos_desc, errores_desc)
|
||
return res
|
||
|
||
|
||
# ==========================================================================
|
||
# Stock de Envato en proyectos viejos, papelera y respaldo
|
||
# ==========================================================================
|
||
|
||
def tipo_medio(rol, ext):
|
||
e = (ext or "").lower()
|
||
if e in R.EXT["video"] or e in R.EXT["video_raw"]:
|
||
return "Video"
|
||
if e in R.EXT["audio"]:
|
||
return "Audio"
|
||
if e in R.EXT["imagen"] or e in R.EXT["foto_raw"]:
|
||
return "Imagenes"
|
||
if e in R.EXT["diseno"]:
|
||
return "Diseño"
|
||
if e in R.EXT["proyecto"]:
|
||
return "Proyectos"
|
||
if e in R.EXT["documento"]:
|
||
return "Documentos"
|
||
if e in R.EXT["fuente"] or e in R.EXT["lut"]:
|
||
return "Fuentes"
|
||
return "Otros"
|
||
|
||
|
||
def limite_meses(meses):
|
||
hoy = datetime.date.today()
|
||
m = hoy.year * 12 + hoy.month - 1 - int(meses)
|
||
return m // 12, m % 12 + 1 # (año, mes) tope: lo anterior es "viejo"
|
||
|
||
|
||
def es_viejo(f, tope):
|
||
"""un archivo es viejo si el mes de su pieza (o su fecha) es anterior al tope"""
|
||
if f["anio_ruta"] and f["mes_ruta"]:
|
||
return (f["anio_ruta"], f["mes_ruta"]) < tope
|
||
return (int(f["fecha"][:4]), int(f["fecha"][5:7])) < tope
|
||
|
||
|
||
def stock_viejo(raiz, meses, tipos):
|
||
con = db()
|
||
tope = limite_meses(meses)
|
||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0", (raiz,)).fetchall()
|
||
con.close()
|
||
grupos = {}
|
||
for f in filas:
|
||
t = tipo_medio(f["rol"], f["ext"])
|
||
# la licencia, el «leeme» y el .zip que vienen dentro de un paquete de Envato se van con él: si
|
||
# no, la lista de acciones seguía contando stock viejo que ningún botón llegaba a apartar
|
||
if t not in tipos and not (t in ("Otros", "Documentos") and tipos):
|
||
continue
|
||
if not es_viejo(f, tope):
|
||
continue
|
||
rel = os.path.relpath(f["dir"], raiz)
|
||
g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "meses": Counter()})
|
||
g["meses"][f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]] += 1
|
||
g["n"] += 1
|
||
g["bytes"] += f["bytes"]
|
||
g["rutas"].append(f["ruta"])
|
||
if len(g["ejemplos"]) < 3:
|
||
g["ejemplos"].append(f["nombre"])
|
||
for g in grupos.values():
|
||
g["mes"] = g.pop("meses").most_common(1)[0][0]
|
||
out = sorted(grupos.values(), key=lambda g: -g["bytes"])
|
||
return {"grupos": out, "n": sum(g["n"] for g in out), "bytes": sum(g["bytes"] for g in out), "tope": f"{tope[0]}-{tope[1]:02d}"}
|
||
|
||
|
||
def stock_a_papelera(raiz, rutas):
|
||
lote = "stock-" + time.strftime("%Y%m%d-%H%M%S")
|
||
movidos, errores = [], []
|
||
for r in rutas:
|
||
try:
|
||
if not os.path.exists(r):
|
||
continue
|
||
if not dentro_de(raiz, r):
|
||
errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca")
|
||
continue
|
||
rel = os.path.relpath(r, raiz)
|
||
mover(r, os.path.join(raiz, R.PAPELERA, "stock", rel), lote, movidos)
|
||
quitar_vacias(os.path.dirname(r), raiz)
|
||
except Exception as ex: # noqa
|
||
errores.append(f"{r}: {ex}")
|
||
actualizar_inventario(movidos)
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(rutas), "movimientos": len(movidos), "errores": errores})
|
||
return {"lote": lote, "movimientos": len(movidos), "errores": errores}
|
||
|
||
|
||
def papelera(raiz):
|
||
"""Qué hay en _Para borrar (y en la _Papelera vieja si existe). Solo lee: borrar es cosa de una persona."""
|
||
out = []
|
||
for carpeta in R.PAPELERAS:
|
||
base = os.path.join(raiz, carpeta)
|
||
if not os.path.isdir(base):
|
||
continue
|
||
for nombre in sorted(os.listdir(base)):
|
||
p = os.path.join(base, nombre)
|
||
if not os.path.isdir(p):
|
||
continue
|
||
n, b = 0, 0
|
||
for d, _, fs in os.walk(p):
|
||
for f in fs:
|
||
try:
|
||
b += os.path.getsize(os.path.join(d, f))
|
||
n += 1
|
||
except OSError:
|
||
pass
|
||
out.append({"carpeta": carpeta, "nombre": nombre, "ruta": p, "n": n, "bytes": b})
|
||
return {"subcarpetas": out, "bytes": sum(x["bytes"] for x in out), "carpeta": os.path.join(raiz, R.PAPELERA)}
|
||
|
||
|
||
def abrir_carpeta(ruta):
|
||
"""Abre una carpeta en el Explorador (solo dentro de alguna raíz configurada)."""
|
||
ruta = os.path.normpath(ruta)
|
||
permitido = any(ruta.lower().startswith(os.path.normpath(r["ruta"]).lower()) for r in CONFIG.get("raices", []))
|
||
if not permitido or not os.path.isdir(ruta):
|
||
return {"ok": False, "motivo": "esa carpeta no está dentro de una raíz"}
|
||
os.startfile(ruta)
|
||
return {"ok": True}
|
||
|
||
|
||
# ==========================================================================
|
||
# Duplicados: se juntan en una subcarpeta «Duplicados» junto al que se queda. Nunca se borran.
|
||
# ==========================================================================
|
||
_EXT_VIDEO_DUP = {".mp4", ".mov", ".mxf", ".braw", ".avi", ".mkv", ".m4v", ".mts", ".r3d"}
|
||
|
||
|
||
def _fuera_de_dup(ruta):
|
||
partes = {R.norm(p) for p in os.path.normpath(ruta).split(os.sep)}
|
||
return not (partes & (R.PAPELERAS_NORM | {R.norm(R.DUPLICADOS)}))
|
||
|
||
|
||
def _mejor(rutas):
|
||
"""Cuál se queda: el que ya esté en una carpeta de grabación, luego el de ruta más corta."""
|
||
def clave(r):
|
||
en_grab = 0 if "grabacion" in R.norm(r.replace(os.sep, " ")) else 1
|
||
return (en_grab, len(r.split(os.sep)), len(r), r.lower())
|
||
return sorted(rutas, key=clave)[0]
|
||
|
||
|
||
def _medir(rutas):
|
||
"""Duración y tamaño de imagen de unos clips. Lo que ya leyó ffprobe está en el inventario; lo demás se
|
||
lee ahora (solo la cabecera) y se apunta para la próxima vez."""
|
||
import concurrent.futures as cf
|
||
out, faltan = {}, []
|
||
con = db()
|
||
for r in rutas:
|
||
f = con.execute("SELECT analizado, dur, ancho, alto, kbps FROM archivos WHERE ruta = ?", (r,)).fetchone()
|
||
if f and f["analizado"] == 1 and f["dur"]:
|
||
out[r] = {"dur": f["dur"], "ancho": f["ancho"] or 0, "alto": f["alto"] or 0, "kbps": f["kbps"] or 0}
|
||
else:
|
||
faltan.append(r)
|
||
if faltan and herramientas()["ffprobe"]["ok"]:
|
||
with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool:
|
||
leidos = list(zip(faltan, pool.map(ffprobe, faltan)))
|
||
with con:
|
||
for r, info in leidos:
|
||
if not info or not info.get("dur"):
|
||
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ? AND analizado = 0", (r,))
|
||
continue
|
||
out[r] = {"dur": info["dur"], "ancho": info["ancho"], "alto": info["alto"], "kbps": info["kbps"]}
|
||
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, "
|
||
"acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
|
||
(info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"],
|
||
info["kbps"], info["alfa"], info["acodec"], info.get("pistas_audio", 1), r))
|
||
con.close()
|
||
return out
|
||
|
||
|
||
_ROLES_TOMA = ("camara_raw", "camara_video", "celular_video")
|
||
|
||
|
||
def _por_comparar(filas):
|
||
"""Clips de cámara que se llaman igual y no pesan lo mismo: candidatos a ser el mismo clip con otra
|
||
calidad. Solo material de cámara: dos exportados con el mismo nombre son dos versiones de una pieza
|
||
(la vertical y la horizontal, la de antes y la de después de un cambio), y cuál es «la buena» no lo
|
||
decide el peso."""
|
||
por_nombre = defaultdict(list)
|
||
for f in filas:
|
||
if (f["ext"] or "").lower() in _EXT_VIDEO_DUP and f["rol"] in _ROLES_TOMA:
|
||
por_nombre[f["nombre"].lower()].append(f)
|
||
return [fs for fs in por_nombre.values() if len(fs) > 1 and len({f["bytes"] for f in fs}) > 1]
|
||
|
||
|
||
def _mismas_tomas(candidatos, tope=400):
|
||
"""El mismo clip guardado dos veces con distinta calidad. Medido el 4-oct-2026 sobre el material real:
|
||
39 clips de un rodaje estaban en dos carpetas con el mismo nombre, unos de 220 MB y otros de 76 MB, y
|
||
el plan no podía juntarlos en su mes porque dos archivos que se llaman igual no caben en una carpeta.
|
||
|
||
Que se llamen igual no basta: dos tarjetas de la misma cámara traen las dos un C0001.MP4. Tienen que
|
||
durar lo mismo. De los que duran lo mismo se queda el de más imagen y, a igualdad, el que más pesa;
|
||
los demás van a «Duplicados» a su lado, para mirarlos a mano. No se borra nada."""
|
||
candidatos = candidatos[:tope]
|
||
medidas = _medir([f["ruta"] for fs in candidatos for f in fs])
|
||
grupos = []
|
||
for fs in candidatos:
|
||
def forma(m): # 1.78 en horizontal, 0.56 en vertical
|
||
return round(m["ancho"] / m["alto"], 1) if m["alto"] else 0
|
||
medidos = sorted(((f, medidas[f["ruta"]]) for f in fs if f["ruta"] in medidas), key=lambda x: (forma(x[1]), x[1]["dur"]))
|
||
racimos, actual = [], []
|
||
for f, m in medidos:
|
||
if actual and (forma(m) != forma(actual[0][1])
|
||
or abs(m["dur"] - actual[0][1]["dur"]) > max(0.5, 0.01 * actual[0][1]["dur"])):
|
||
racimos.append(actual)
|
||
actual = []
|
||
actual.append((f, m))
|
||
if actual:
|
||
racimos.append(actual)
|
||
for rc in racimos:
|
||
if len(rc) < 2 or len({f["bytes"] for f, _ in rc}) < 2:
|
||
continue
|
||
rc.sort(key=lambda x: (-(x[1]["ancho"] * x[1]["alto"]), -(x[0]["bytes"] or 0), len(x[0]["ruta"])))
|
||
(fq, mq), resto = rc[0], rc[1:]
|
||
|
||
def dice(f, m):
|
||
return f"{m['ancho']}x{m['alto']} · {_gb(f['bytes'])}"
|
||
grupos.append({"tipo": "calidad", "nombre": fq["nombre"], "queda": fq["ruta"], "mueven": [f["ruta"] for f, _ in resto],
|
||
"bytes": sum(f["bytes"] or 0 for f, _ in resto),
|
||
"destino": os.path.join(os.path.dirname(fq["ruta"]), R.DUPLICADOS),
|
||
"detalle": "se queda la de " + dice(fq, mq) + "; se aparta la de " + ", ".join(dice(f, m) for f, m in resto)})
|
||
return grupos
|
||
|
||
|
||
_DUP_CACHE = {}
|
||
|
||
|
||
def duplicados(raiz, minimo=5_000_000, calidades=False):
|
||
"""Los repetidos de una carpeta, guardados hasta que algo se mueva en ella (o pasen 10 minutos): Inicio,
|
||
«Qué hay» y el diagnóstico los piden cada vez, y en Vía Sumapaz (76.559 archivos) costaban 22 s cada vez."""
|
||
k = (os.path.normcase(os.path.normpath(raiz)), minimo, bool(calidades))
|
||
marca = (sum(v for kk, v in PLAN_GEN.items() if kk.lower() == k[0]), _ts_escaneo(raiz))
|
||
c = _DUP_CACHE.get(k)
|
||
if c and c[0] == marca and time.time() - c[1] < 600:
|
||
return c[2]
|
||
r = _duplicados(raiz, minimo, calidades)
|
||
_DUP_CACHE[k] = (marca, time.time(), r)
|
||
return r
|
||
|
||
|
||
def _ts_escaneo(raiz):
|
||
con = db()
|
||
f = con.execute("SELECT ts FROM escaneos WHERE raiz = ?", (os.path.normpath(raiz),)).fetchone()
|
||
con.close()
|
||
return f["ts"] if f else 0
|
||
|
||
|
||
def _solo_existentes(filas):
|
||
"""Quita los que ya no están en el disco, pero solo pregunta por los que pueden formar grupo (mismo nombre
|
||
y peso, el mismo clip en dos formatos, o con dos pesos), y en paralelo. Medido el 14-oct-2026 en Vía
|
||
Sumapaz: comprobar uno a uno los 30.963 archivos de más de 5 MB por la red tardaba 20 de 22 segundos."""
|
||
por_nb = Counter((f["nombre"].lower(), f["bytes"]) for f in filas)
|
||
exts, pesos = defaultdict(set), defaultdict(set)
|
||
for f in filas:
|
||
e = (f["ext"] or "").lower()
|
||
if e in _EXT_VIDEO_DUP:
|
||
exts[os.path.splitext(f["nombre"])[0].lower()].add(e)
|
||
pesos[f["nombre"].lower()].add(f["bytes"])
|
||
|
||
def puede(f):
|
||
if por_nb[(f["nombre"].lower(), f["bytes"])] > 1:
|
||
return True
|
||
return (f["ext"] or "").lower() in _EXT_VIDEO_DUP and (
|
||
len(exts[os.path.splitext(f["nombre"])[0].lower()]) > 1 or len(pesos[f["nombre"].lower()]) > 1)
|
||
dudosos = [f["ruta"] for f in filas if puede(f)]
|
||
if not dudosos:
|
||
return filas
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=24) as ex:
|
||
existe = dict(zip(dudosos, ex.map(os.path.exists, dudosos)))
|
||
return [f for f in filas if existe.get(f["ruta"], True)]
|
||
|
||
|
||
def _duplicados(raiz, minimo=5_000_000, calidades=False):
|
||
"""Grupos de archivos repetidos. Tres clases:
|
||
identico = mismo nombre y mismo tamaño en sitios distintos.
|
||
version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede
|
||
cambiar el color o el peso, así que se deja para revisar a mano.
|
||
calidad = mismo nombre y misma duración, distinto peso: el mismo clip con otra calidad. Hay que leer
|
||
los clips para saberlo, así que solo se calcula si se pide (`calidades`); si no, se dice
|
||
cuántos hay por comparar."""
|
||
con = db()
|
||
filas = [dict(r) for r in con.execute(
|
||
"SELECT ruta, dir, nombre, ext, bytes, rol FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0",
|
||
(os.path.normpath(raiz), minimo)).fetchall()]
|
||
con.close()
|
||
filas = _solo_existentes([f for f in filas if _fuera_de_dup(f["ruta"])])
|
||
grupos, usados = [], set()
|
||
por_nombre = {}
|
||
for f in filas:
|
||
por_nombre.setdefault((f["nombre"].lower(), f["bytes"]), []).append(f)
|
||
for (nombre, b), fs in por_nombre.items():
|
||
if len(fs) < 2:
|
||
continue
|
||
rutas = [f["ruta"] for f in fs]
|
||
queda = _mejor(rutas)
|
||
mueven = [r for r in rutas if r != queda]
|
||
usados.update(rutas)
|
||
grupos.append({"tipo": "identico", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven,
|
||
"bytes": b * len(mueven), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)})
|
||
por_raiz = {}
|
||
for f in filas:
|
||
if f["ruta"] in usados or (f["ext"] or "").lower() not in _EXT_VIDEO_DUP:
|
||
continue
|
||
stem = os.path.splitext(f["nombre"])[0].lower()
|
||
if len(stem) < 6:
|
||
continue
|
||
por_raiz.setdefault(stem, []).append(f)
|
||
for stem, fs in por_raiz.items():
|
||
if len({(f["ext"] or "").lower() for f in fs}) < 2:
|
||
continue
|
||
fs = sorted(fs, key=lambda f: -(f["bytes"] or 0))
|
||
queda = fs[0]["ruta"] # el más pesado suele ser el original
|
||
mueven = [f["ruta"] for f in fs[1:]]
|
||
grupos.append({"tipo": "version", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven,
|
||
"bytes": sum(f["bytes"] or 0 for f in fs[1:]), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)})
|
||
for g in grupos:
|
||
if g["tipo"] == "version":
|
||
usados.add(g["queda"])
|
||
usados.update(g["mueven"])
|
||
candidatos = _por_comparar([f for f in filas if f["ruta"] not in usados])
|
||
if calidades and candidatos:
|
||
grupos += _mismas_tomas(candidatos)
|
||
grupos.sort(key=lambda g: -g["bytes"])
|
||
return {"grupos": grupos, "n": sum(len(g["mueven"]) for g in grupos), "bytes": sum(g["bytes"] for g in grupos),
|
||
"identicos": sum(1 for g in grupos if g["tipo"] == "identico"), "versiones": sum(1 for g in grupos if g["tipo"] == "version"),
|
||
"calidades": sum(1 for g in grupos if g["tipo"] == "calidad"),
|
||
# clips que se llaman igual y pesan distinto, sin comparar todavía
|
||
"por_comparar": 0 if calidades else sum(len(fs) - 1 for fs in candidatos)}
|
||
|
||
|
||
def huella_rapida(ruta, trozo=1 << 20):
|
||
"""sha256 del primer MB, uno del centro y el último (y del tamaño): suficiente para distinguir dos archivos que solo
|
||
coinciden en nombre y peso, sin leer gigas por la red."""
|
||
h = hashlib.sha256()
|
||
tam = os.path.getsize(ruta)
|
||
h.update(str(tam).encode())
|
||
with open(ruta, "rb") as fh:
|
||
h.update(fh.read(trozo))
|
||
if tam > trozo * 3:
|
||
fh.seek(tam // 2)
|
||
h.update(fh.read(trozo))
|
||
if tam > trozo * 2:
|
||
fh.seek(tam - trozo)
|
||
h.update(fh.read(trozo))
|
||
return h.hexdigest()
|
||
|
||
|
||
def apartar_duplicados(raiz, grupos=None):
|
||
"""Mueve cada copia a <carpeta del que se queda>/Duplicados. Reversible por lote; nada se borra.
|
||
Las copias «idénticas» se comprueban por contenido antes de moverlas: si difieren, se dejan y se avisa."""
|
||
raiz = os.path.normpath(raiz)
|
||
if grupos is None:
|
||
grupos = duplicados(raiz, calidades=True)["grupos"]
|
||
lote = "dup-" + time.strftime("%Y%m%d-%H%M%S")
|
||
movidos, errores, distintos = [], [], 0
|
||
for g in grupos:
|
||
ref = None
|
||
if g.get("tipo") == "identico":
|
||
try:
|
||
ref = huella_rapida(g["queda"])
|
||
except OSError as ex:
|
||
errores.append(f"{g['queda']}: no se pudo leer para comparar ({ex}); el grupo se deja")
|
||
continue
|
||
for r in g["mueven"]:
|
||
try:
|
||
if not os.path.exists(r):
|
||
continue
|
||
if ref is not None and huella_rapida(r) != ref:
|
||
distintos += 1
|
||
errores.append(f"{os.path.relpath(r, raiz)}: mismo nombre y peso pero contenido distinto; se deja donde está")
|
||
continue
|
||
mover(r, os.path.join(g["destino"], os.path.basename(r)), lote, movidos)
|
||
quitar_vacias(os.path.dirname(r), raiz)
|
||
except Exception as ex: # noqa
|
||
errores.append(f"{r}: {ex}")
|
||
actualizar_inventario(movidos)
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(grupos), "movimientos": len(movidos), "errores": errores})
|
||
return {"lote": lote, "grupos": len(grupos), "movimientos": len(movidos), "errores": errores, "distintos": distintos}
|
||
|
||
|
||
RESPALDO = {"estado": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "errores": [], "destino": "", "cancelar": False, "inicio": 0, "copiados": 0, "saltados": 0}
|
||
|
||
|
||
def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True, meses=0):
|
||
con = db()
|
||
rc = raiz_cfg(raiz)
|
||
cliente = rc["cliente"] if rc else os.path.basename(raiz.rstrip("\\/")) or "Raiz"
|
||
base = os.path.join(destino, re.sub(r'[<>:"/\\|?*]', "_", cliente))
|
||
conv = {}
|
||
for t in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('verificado','reemplazado')").fetchall():
|
||
if os.path.exists(t["salida"]):
|
||
conv[os.path.normpath(t["ruta"]).lower()] = t["salida"]
|
||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0", (raiz,)).fetchall()
|
||
con.close()
|
||
marcas_p = [os.sep + p + os.sep for p in R.PAPELERAS]
|
||
marca_a = os.sep + "_Archivo" + os.sep
|
||
items, vistos = [], set()
|
||
omit_raw, omit_stock_b, omit_stock_n, convertidos = [], 0, 0, 0
|
||
tope = limite_meses(meses) if int(meses or 0) > 0 else None # solo lo anterior a este (año, mes)
|
||
omit_reciente_n, omit_reciente_b = 0, 0
|
||
|
||
def agregar(ruta, rol, ext, bytes_, mtime):
|
||
k = os.path.normpath(ruta).lower()
|
||
if k in vistos:
|
||
return
|
||
vistos.add(k)
|
||
rel_dir = os.path.relpath(os.path.dirname(ruta), raiz)
|
||
rel_dir = "" if rel_dir == "." else rel_dir
|
||
tipo = tipo_medio(rol, ext)
|
||
items.append({"origen": ruta, "rel_dir": rel_dir, "nombre": os.path.basename(ruta), "bytes": bytes_, "tipo": tipo, "mtime": mtime, "rel": os.path.relpath(ruta, raiz)})
|
||
|
||
for f in filas:
|
||
if any(m in f["ruta"] for m in marcas_p) or marca_a in f["ruta"]:
|
||
continue
|
||
if sin_stock and f["es_stock"]:
|
||
omit_stock_b += f["bytes"]
|
||
omit_stock_n += 1
|
||
continue
|
||
if tope and not es_viejo(f, tope):
|
||
omit_reciente_n += 1
|
||
omit_reciente_b += f["bytes"]
|
||
continue
|
||
k = os.path.normpath(f["ruta"]).lower()
|
||
if k in conv:
|
||
sal = conv[k]
|
||
try:
|
||
st = os.stat(sal)
|
||
except OSError:
|
||
continue
|
||
convertidos += 1
|
||
vistos.add(k)
|
||
agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime)
|
||
continue
|
||
if f["rol"] == "camara_raw" and not incluir_raw:
|
||
omit_raw.append({"rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"]})
|
||
continue
|
||
agregar(f["ruta"], f["rol"], f["ext"], f["bytes"], f["mtime"])
|
||
for k, sal in conv.items():
|
||
if os.path.normpath(sal).lower().startswith(os.path.normpath(raiz).lower() + os.sep) and not any(m in sal for m in marcas_p):
|
||
try:
|
||
st = os.stat(sal)
|
||
agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime)
|
||
except OSError:
|
||
pass
|
||
# la subcarpeta por tipo solo se añade donde una carpeta mezcla tipos de medio
|
||
# ("Audio/" ya es solo audio: no hace falta "Audio/Audio/")
|
||
tipos_por_dir = defaultdict(set)
|
||
for it in items:
|
||
tipos_por_dir[it["rel_dir"]].add(it["tipo"])
|
||
for it in items:
|
||
mezcla = por_tipo and len(tipos_por_dir[it["rel_dir"]]) > 1
|
||
partes = [base, it["rel_dir"], it["tipo"] if mezcla else "", it["nombre"]]
|
||
it["destino"] = os.path.normpath(os.path.join(*[p for p in partes if p]))
|
||
por_tipo_res = defaultdict(lambda: {"n": 0, "bytes": 0})
|
||
for it in items:
|
||
por_tipo_res[it["tipo"]]["n"] += 1
|
||
por_tipo_res[it["tipo"]]["bytes"] += it["bytes"]
|
||
omit_raw.sort(key=lambda x: -x["bytes"])
|
||
return {
|
||
"cliente": cliente, "base": base, "destino_base": destino, "n": len(items), "bytes": sum(i["bytes"] for i in items),
|
||
"por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])],
|
||
"convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]},
|
||
"stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
|
||
"reciente_omitido": {"n": omit_reciente_n, "bytes": omit_reciente_b, "meses": int(meses or 0)},
|
||
"muestra": [{"de": i["rel"], "a": os.path.relpath(i["destino"], destino)} for i in items[:25]],
|
||
"items": items,
|
||
}
|
||
|
||
|
||
def copiar_respaldo(raiz, plan):
|
||
RESPALDO.update(estado="corriendo", hechos=0, total=plan["n"], bytes=0, bytes_total=plan["bytes"], actual="", errores=[],
|
||
destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0)
|
||
indice = []
|
||
# un respaldo por lotes (solo lo de más de 6 meses, luego el resto) reescribía el índice con lo de la
|
||
# última tanda y dejaba fuera todo lo anterior: se arranca de lo que ya había.
|
||
previo = {}
|
||
try:
|
||
import csv as _csv
|
||
with open(os.path.join(plan["base"], "INDICE.csv"), newline="", encoding="utf-8-sig") as fh:
|
||
filas = list(_csv.reader(fh, delimiter=";"))
|
||
for fila in filas[1:]:
|
||
if len(fila) >= 5:
|
||
previo[fila[0]] = tuple(fila)
|
||
except (OSError, ValueError):
|
||
pass
|
||
for it in plan["items"]:
|
||
if RESPALDO["cancelar"]:
|
||
RESPALDO["estado"] = "cancelado"
|
||
break
|
||
d = it["destino"]
|
||
RESPALDO["actual"] = it["rel"]
|
||
try:
|
||
os.makedirs(os.path.dirname(d), exist_ok=True)
|
||
ya = False
|
||
if os.path.exists(d):
|
||
st = os.stat(d)
|
||
# contra el archivo de ORIGEN tal como está ahora, no contra lo que decía el último escaneo:
|
||
# si se editó después de escanear, la copia vieja «coincidía» y no se volvía a copiar nunca
|
||
try:
|
||
so = os.stat(it["origen"])
|
||
tam_o, mt_o = so.st_size, so.st_mtime
|
||
except OSError:
|
||
tam_o, mt_o = it["bytes"], it["mtime"]
|
||
ya = st.st_size == tam_o and abs(st.st_mtime - mt_o) < 2
|
||
if ya:
|
||
RESPALDO["saltados"] += 1
|
||
else:
|
||
tmp = d + ".parcial"
|
||
shutil.copy2(it["origen"], tmp)
|
||
if os.path.getsize(tmp) != os.path.getsize(it["origen"]):
|
||
raise OSError("tamaño distinto tras copiar")
|
||
os.replace(tmp, d)
|
||
RESPALDO["copiados"] += 1
|
||
indice.append((os.path.relpath(d, plan["base"]), it["rel"], it["tipo"], it["bytes"], datetime.datetime.fromtimestamp(it["mtime"]).strftime("%Y-%m-%d")))
|
||
except Exception as ex: # noqa
|
||
RESPALDO["errores"].append(f"{it['rel']}: {ex}")
|
||
RESPALDO["hechos"] += 1
|
||
RESPALDO["bytes"] += it["bytes"]
|
||
try:
|
||
os.makedirs(plan["base"], exist_ok=True)
|
||
import csv
|
||
# lo de esta tanda manda sobre lo que ya había; lo anterior se conserva
|
||
for fila in indice:
|
||
previo[fila[0]] = fila
|
||
todo = sorted(previo.values(), key=lambda x: str(x[0]).lower())
|
||
with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh:
|
||
w = csv.writer(fh, delimiter=";")
|
||
w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"])
|
||
w.writerows(todo)
|
||
resumen = defaultdict(lambda: defaultdict(lambda: [0, 0]))
|
||
for rel_d, rel_o, tipo, b, _ in todo:
|
||
carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else ""
|
||
resumen[carpeta][tipo][0] += 1
|
||
resumen[carpeta][tipo][1] += int(b or 0)
|
||
with open(os.path.join(plan["base"], "_MAPA.md"), "w", encoding="utf-8") as fh:
|
||
fh.write(f"# Respaldo de {plan['cliente']}\n\nOrigen: `{raiz}` \nFecha: {time.strftime('%Y-%m-%d %H:%M')} \n"
|
||
f"Archivos: {len(indice)} · {sum(i[3] for i in indice)/1e9:.1f} GB\n\n")
|
||
fh.write("Los clips de cámara están en MP4 H.265 10 bits (Main10). Los BRAW/originales pesados no se copian salvo que se pida.\n\n")
|
||
fh.write("| Carpeta | Tipo | Archivos | GB |\n|---|---|---:|---:|\n")
|
||
for carpeta in sorted(resumen):
|
||
for tipo, (n, b) in sorted(resumen[carpeta].items()):
|
||
fh.write(f"| {carpeta or '(raíz)'} | {tipo} | {n} | {b/1e9:.2f} |\n")
|
||
with open(os.path.join(plan["base"], "manifest.json"), "w", encoding="utf-8") as fh:
|
||
json.dump({"cliente": plan["cliente"], "origen": raiz, "fecha": time.time(), "archivos": len(indice), "bytes": sum(i[3] for i in indice),
|
||
"errores": RESPALDO["errores"]}, fh, ensure_ascii=False, indent=1)
|
||
except Exception as ex: # noqa
|
||
RESPALDO["errores"].append(f"índice: {ex}")
|
||
if RESPALDO["estado"] == "corriendo":
|
||
RESPALDO["estado"] = "listo"
|
||
bitacora({"lote": "respaldo", "op": "respaldo", "raiz": raiz, "a": plan["base"], "archivos": RESPALDO["hechos"], "copiados": RESPALDO["copiados"],
|
||
"saltados": RESPALDO["saltados"], "bytes": RESPALDO["bytes"], "errores": len(RESPALDO["errores"])})
|
||
|
||
|
||
# ==========================================================================
|
||
# Servidor HTTP
|
||
# ==========================================================================
|
||
|
||
PLANES = {} # raiz -> plan
|
||
PLAN_GEN = defaultdict(int) # raiz -> cuántas veces se ha olvidado su plan: un recálculo viejo no pisa uno nuevo
|
||
REPLAN = {} # raiz -> el hilo que está volviendo a pensar el plan después de mover algo
|
||
PLANES_RESPALDO = {}
|
||
|
||
|
||
def olvidar_plan(raiz, recalcular=True):
|
||
"""Después de mover algo, el plan calculado ya no describe la carpeta: se olvida y, salvo que se diga lo
|
||
contrario, se vuelve a pensar en segundo plano. Así Inicio dice lo que de verdad queda (o que no queda
|
||
nada) en vez de ofrecer otra vez lo que se acaba de hacer, o de pedir que se calcule. Medido el
|
||
14-oct-2026 en el laboratorio: tras «Hacer las 5 de una vez», Inicio seguía diciendo «Ordenar por año y
|
||
mes · 5 cambios · Ver y aplicar» y «Plan listo: 7 de 9 movimientos marcados» con todo ya movido, porque
|
||
el plan solo se olvidaba al aplicar desde la pestaña «Qué va a pasar»; y tras «Deshacer», al revés:
|
||
«Lo seguro ya está hecho» con la carpeta otra vez en desorden."""
|
||
k = _clave(raiz)
|
||
PLANES.pop(k, None)
|
||
PLAN_GEN[k] += 1
|
||
if not recalcular or not raiz_cfg(k):
|
||
return
|
||
gen = PLAN_GEN[k]
|
||
|
||
def _pensar():
|
||
try:
|
||
plan = Plan(raiz_cfg(k)).correr()
|
||
if PLAN_GEN[k] == gen: # nadie movió nada más mientras tanto
|
||
PLANES[k] = plan
|
||
except Exception as ex: # noqa
|
||
print(f"no se pudo volver a pensar el plan de {k}: {ex}", file=sys.stderr)
|
||
finally:
|
||
if REPLAN.get(k) is threading.current_thread():
|
||
REPLAN.pop(k, None)
|
||
|
||
t = threading.Thread(target=_pensar, daemon=True)
|
||
REPLAN[k] = t
|
||
t.start()
|
||
|
||
|
||
def plan_en_marcha(raiz):
|
||
"""Si el plan de esta carpeta se está volviendo a pensar, espera a que acabe y lo devuelve; si no, None.
|
||
Lo usan «Calcular» y la pasada automática para no recorrer la carpeta dos veces seguidas."""
|
||
k = _clave(raiz)
|
||
t = REPLAN.get(k)
|
||
if t is None or t is threading.current_thread():
|
||
return None
|
||
t.join()
|
||
return PLANES.get(k)
|
||
|
||
|
||
def _clave(ruta):
|
||
return os.path.normpath(ruta)
|
||
|
||
|
||
_ELIGIENDO = threading.Lock()
|
||
VENTANA = None # la ventana del programa (ventana.Ventana) mientras está abierta
|
||
ULTIMA_PETICION = [0.0] # cuándo preguntó algo la interfaz por última vez
|
||
|
||
|
||
def elegir_carpeta(titulo="Elige la carpeta del cliente"):
|
||
"""Abre el selector de carpetas de Windows y devuelve la ruta o None. Con la ventana del programa abierta
|
||
es el suyo, modal sobre ella; si la interfaz va en el navegador, uno aparte en PowerShell."""
|
||
if not _ELIGIENDO.acquire(blocking=False):
|
||
raise ValueError("ya hay un selector de carpetas abierto")
|
||
try:
|
||
v = VENTANA
|
||
if v is not None:
|
||
ruta = v.elegir_carpeta(titulo)
|
||
return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None
|
||
ps = ("Add-Type -AssemblyName System.Windows.Forms; "
|
||
"$d = New-Object System.Windows.Forms.FolderBrowserDialog; "
|
||
"$d.Description = '" + titulo.replace("'", "''") + "'; $d.ShowNewFolderButton = $false; "
|
||
"$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true; "
|
||
"if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }")
|
||
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||
out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", ps], capture_output=True, timeout=600, creationflags=flags)
|
||
ruta = out.stdout.decode("utf-8", "replace").strip() or out.stdout.decode("mbcs", "replace").strip()
|
||
return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None
|
||
finally:
|
||
_ELIGIENDO.release()
|
||
|
||
|
||
def _anidadas(raices):
|
||
"""(externa, interna) si alguna carpeta de la lista está dentro de otra. El inventario va por raíz y
|
||
`archivos.ruta` es única: al escanear la externa, las filas de la interna cambian de dueño y la interna
|
||
se queda vacía sin decir nada (su respaldo copiaría cero archivos y terminaría «bien»)."""
|
||
rutas = [os.path.normpath(r["ruta"]) for r in raices if r.get("ruta")]
|
||
for a in rutas:
|
||
for b in rutas:
|
||
if a is not b and a.lower() != b.lower() and dentro_de(a, b):
|
||
return a, b
|
||
return None
|
||
|
||
|
||
def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False):
|
||
"""Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear).
|
||
Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están
|
||
convirtiendo ahora mismo nunca se descartan desde aquí: hay que detener la cola primero."""
|
||
k = _clave(ruta)
|
||
r = raiz_cfg(ruta)
|
||
if not r:
|
||
raise ValueError("esa carpeta no está registrada")
|
||
if (PROGRESO.get(k) or {}).get("estado") == "corriendo" or (ANALISIS.get(k) or {}).get("estado") == "corriendo":
|
||
raise ValueError("la carpeta se está escaneando o analizando; espera a que termine")
|
||
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"] and (APLICANDO.get("raiz") or "").lower() == k.lower():
|
||
raise ValueError("se está aplicando el plan de esa carpeta; espera a que termine")
|
||
recuperar_huerfanas()
|
||
con = db()
|
||
activos = [dict(x) for x in con.execute("SELECT id, ruta, estado, pct FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo') ORDER BY id", (k.lower(),)).fetchall()]
|
||
if activos:
|
||
en_curso = [x for x in activos if x["estado"] == "corriendo"]
|
||
if en_curso or not descartar_cola:
|
||
con.close()
|
||
n = len(activos)
|
||
por_estado = {"corriendo": len(en_curso), "pendiente": n - len(en_curso)}
|
||
que = ", ".join(f"{v} {'convirtiéndose ahora' if e == 'corriendo' else 'en espera'}" for e, v in por_estado.items() if v)
|
||
# El texto dice QUÉ hay y QUÉ hacer; los datos (archivos, estados) van aparte para que la
|
||
# interfaz los enseñe y ofrezca descartar. Antes solo decía «detén la cola o límpiala».
|
||
if en_curso:
|
||
msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): detén la cola en Convertir a 10 bits y vuelve a intentarlo"
|
||
else:
|
||
msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): termínalas con Reanudar en Convertir a 10 bits, o descártalas al desconectar"
|
||
raise ErrorCola(msg, {"cola": {"n": n, "por_estado": por_estado, "en_curso": bool(en_curso),
|
||
"archivos": [os.path.basename(x["ruta"]) for x in activos[:6]]}})
|
||
with con:
|
||
con.execute("DELETE FROM trabajos WHERE lower(raiz) = ? AND estado = 'pendiente'", (k.lower(),))
|
||
bitacora({"op": "cola_descartada", "raiz": k, "n": len(activos), "archivos": [x["ruta"] for x in activos]})
|
||
CONFIG["raices"] = [x for x in CONFIG["raices"] if _clave(x["ruta"]).lower() != k.lower()]
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
PLANES.pop(k, None); PROGRESO.pop(k, None); ANALISIS.pop(k, None)
|
||
if borrar_datos:
|
||
for t in ("archivos", "carpetas", "escaneos"):
|
||
con.execute(f"DELETE FROM {t} WHERE lower(raiz) = ?", (k.lower(),))
|
||
con.commit()
|
||
con.close()
|
||
bitacora({"op": "raiz_quitada", "raiz": k, "datos_borrados": bool(borrar_datos)})
|
||
return {"ok": True, "raices": len(CONFIG["raices"])}
|
||
|
||
|
||
def estado_general():
|
||
"""Todo lo que la interfaz sondea cada pocos segundos, en una sola respuesta."""
|
||
con = db()
|
||
esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()}
|
||
cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()}
|
||
ids = sorted(COLA["actuales"])
|
||
actuales = [dict(t) for t in con.execute(f"SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id IN ({','.join('?' * len(ids))})",
|
||
ids)] if ids else []
|
||
actual = next((a for a in actuales if a["id"] == COLA["actual"]), actuales[0] if actuales else None)
|
||
con.close()
|
||
# «clip 3 de 12»: los hechos en esta tanda + los que van + los que esperan
|
||
total_tanda = COLA["hechos"] + len(actuales) + 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, "actuales": actuales, "mensaje": COLA["mensaje"], "n": cola_n,
|
||
"hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]},
|
||
"aplicando": APLICANDO, "asistente": asistente.ESTADO,
|
||
"respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])},
|
||
"traer": {k: v for k, v in TRAER.items() if k != "errores"} | {"errores_n": len(TRAER.get("errores") or [])},
|
||
"automatico": dict(AUTO),
|
||
"cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "formatos": {k: {"nombre": v["nombre"], "que": v["que"], "ext": v["ext"]} for k, v in FORMATOS.items()}, "plantillas": PLANTILLAS}
|
||
|
||
|
||
def _plural(n, uno, varios):
|
||
return f"{n:,}".replace(",", ".") + " " + (uno if n == 1 else varios)
|
||
|
||
|
||
# Qué enseñar en lugar de cada variable de una plantilla, y cómo se llama en llano.
|
||
EJEMPLO_VAR = {
|
||
"anio": ("2026", "el año del material"),
|
||
"mes": ("09- Septiembre", "el mes del material"),
|
||
"dia": ("15- Viernes", "el día en que se grabó"),
|
||
"nombre": ("Nombre de la pieza", "el nombre de la pieza o de la carpeta"),
|
||
"fuente": ("URSA_BRAW", "la cámara de la que salió"),
|
||
"app": ("Premiere", "el programa que abre el proyecto"),
|
||
}
|
||
# Estas dos casi siempre están vacías (el archivo cae directo en la carpeta de arriba), así que no son
|
||
# un nivel más del árbol: son una nota en el nivel que las contiene.
|
||
VAR_NOTA = {
|
||
"sub": "y si el material venía dentro de una subcarpeta, se conserva",
|
||
"rel": "se copia la ruta tal cual venía, para poder devolverla a su sitio",
|
||
}
|
||
|
||
|
||
def mapa_estructura(tipo, raiz=None):
|
||
"""El árbol de carpetas que produce una plantilla, con qué cae en cada una y cuánto hay ya.
|
||
|
||
Es la respuesta a «¿dónde va a acabar mi material?» antes de mover nada. Se arma con las plantillas
|
||
de verdad, así que lo que enseña es exactamente lo que la app va a hacer, y si se edita una
|
||
plantilla el árbol cambia con ella."""
|
||
tipo = tipo if tipo in PLANTILLAS else "contenido"
|
||
pl = PLANTILLAS[tipo]
|
||
|
||
# qué hay hoy en la carpeta, repartido por cubeta según el rol de cada archivo
|
||
hay, apps, cliente = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0]), ""
|
||
if raiz:
|
||
raiz = _clave(raiz)
|
||
r = raiz_cfg(raiz)
|
||
cliente = r["cliente"] if r else ""
|
||
con = db()
|
||
for f in con.execute("SELECT rol, ext, COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos "
|
||
"WHERE raiz = ? GROUP BY rol, ext", (raiz,)).fetchall():
|
||
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
|
||
hay[cub][0] += f["n"]
|
||
hay[cub][1] += f["b"]
|
||
if cub == "PROYECTOS":
|
||
a = R.app_proyecto(f["ext"]) or "Sin programa reconocido"
|
||
apps[a][0] += f["n"]
|
||
apps[a][1] += f["b"]
|
||
con.close()
|
||
|
||
# nombres de archivo de verdad, para poder probar el mapa con lo que hay en el disco
|
||
ejemplos = defaultdict(list)
|
||
if raiz:
|
||
con = db()
|
||
for f in con.execute("SELECT rol, nombre, MAX(bytes) b FROM archivos WHERE raiz = ? AND es_basura = 0 "
|
||
"AND es_proxy = 0 GROUP BY rol, ext ORDER BY b DESC", (raiz,)).fetchall():
|
||
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
|
||
if len(ejemplos[cub]) < 4:
|
||
ejemplos[cub].append(f["nombre"])
|
||
con.close()
|
||
|
||
# cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta
|
||
roles = defaultdict(list)
|
||
for rol, cub in R.ROL_A_CUBETA.items():
|
||
roles[cub].append(R.ROL_QUE.get(rol, rol))
|
||
|
||
raiz_nodo = {"nombre": os.path.basename(raiz.rstrip(os.sep)) or raiz if raiz else "Carpeta del cliente",
|
||
"hijos": [], "cubetas": [], "variable": "", "nota": "", "crudo": [], "color": "",
|
||
"n": 0, "bytes": 0}
|
||
|
||
def bajar(nodo, nombre, variable, crudo):
|
||
for h in nodo["hijos"]:
|
||
if h["nombre"].lower() == nombre.lower():
|
||
return h
|
||
h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "",
|
||
"crudo": list(crudo), "color": "", "n": 0, "bytes": 0}
|
||
nodo["hijos"].append(h)
|
||
return h
|
||
|
||
cubetas = []
|
||
for cub, titulo in R.CUBETAS.items():
|
||
plantilla = pl.get(cub, "")
|
||
n, b = hay.get(cub, [0, 0])
|
||
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
|
||
"asignable": cub not in ("CONTENEDOR", "IGNORAR", "BANCO"),
|
||
"color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
|
||
"ejemplos": ejemplos.get(cub, []),
|
||
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
|
||
"guarda": sorted(set(roles.get(cub, []))), "n": n, "bytes": b,
|
||
"apps": (sorted(({"nombre": k, "n": v[0], "bytes": v[1]} for k, v in apps.items()),
|
||
key=lambda x: -x["bytes"]) if cub == "PROYECTOS" else [])})
|
||
if not plantilla:
|
||
continue
|
||
nodo, crudo = raiz_nodo, []
|
||
for seg in plantilla.split("/"):
|
||
if seg == "{app}":
|
||
crudo.append(seg)
|
||
nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1], crudo)
|
||
nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia "
|
||
"carpeta, se queda con el nombre de esa carpeta")
|
||
nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?»
|
||
continue
|
||
nota = next((v for k, v in VAR_NOTA.items() if seg == "{" + k + "}"), "")
|
||
if nota:
|
||
nodo["nota"] = nota # no baja un nivel: se queda en la carpeta de arriba
|
||
continue
|
||
texto, hint = seg, ""
|
||
for k, (ej, que) in EJEMPLO_VAR.items():
|
||
if "{" + k + "}" in texto:
|
||
texto = texto.replace("{" + k + "}", ej)
|
||
hint = que if not hint else hint + " y " + que
|
||
crudo.append(seg)
|
||
nodo = bajar(nodo, texto, hint, crudo)
|
||
nodo["cubetas"].append(cub)
|
||
nodo["n"] += n
|
||
nodo["bytes"] += b
|
||
|
||
def ordenar(nodo):
|
||
# una carpeta con nombre fijo antes que una variable, y dentro de cada grupo por nombre
|
||
nodo["hijos"].sort(key=lambda h: (bool(h["variable"]), h["nombre"].lower()))
|
||
cols = {R.COLOR_CUBETA.get(c, "") for c in nodo["cubetas"]}
|
||
for h in nodo["hijos"]:
|
||
ordenar(h)
|
||
nodo["n"] += h["n"]
|
||
nodo["bytes"] += h["bytes"]
|
||
cols.add(h["color"])
|
||
cols.discard("")
|
||
# una carpeta toma el color de lo que guarda; si mezcla tipos de distinto color, se queda neutra
|
||
nodo["color"] = cols.pop() if len(cols) == 1 else ""
|
||
ordenar(raiz_nodo)
|
||
|
||
return {"tipo": tipo, "titulo": R.TIPOS_CLIENTE.get(tipo, tipo), "raiz": raiz or "", "cliente": cliente,
|
||
"arbol": raiz_nodo, "cubetas": cubetas, "variables": {k: v[1] for k, v in EJEMPLO_VAR.items()},
|
||
"colores": R.COLOR_CUBETA, "pintado": _esta_pintada(raiz) if raiz else False}
|
||
|
||
|
||
def donde_iria(nombre, tipo="contenido"):
|
||
"""Qué es un archivo y en qué carpeta lo dejaría la app, solo por su nombre.
|
||
|
||
Es el mapa al revés: en vez de «esta carpeta guarda esto», «esto va a esta carpeta». Vale para
|
||
cualquier nombre, exista o no, porque lo decide el mismo clasificador que usa el escaneo."""
|
||
nombre = (nombre or "").strip().strip('"').replace("/", os.sep).split(os.sep)[-1]
|
||
if not nombre:
|
||
return {"error": "escribe el nombre de un archivo"}
|
||
ext = os.path.splitext(nombre)[1].lower()
|
||
firma = R.firma_archivo(nombre)
|
||
rol = R.rol_archivo(nombre, ext, firma)
|
||
cub = R.ROL_A_CUBETA.get(rol, "REVISAR")
|
||
fecha, origen = R.fecha_de_nombre(nombre, time.time())
|
||
kw = {"anio": int(fecha[:4]), "mes": int(fecha[5:7])}
|
||
if cub == "GRABACION":
|
||
kw["sub"] = R.FUENTE_CARPETA.get(firma, "AUDIO" if rol == "audio_grabado" else "OTROS")
|
||
if cub == "CELULAR" and tipo == "contenido":
|
||
cub, kw["sub"] = "GRABACION", "CELULAR"
|
||
if cub in ("GRABACION", "CELULAR"):
|
||
kw["dia"] = R.nombre_dia(fecha)
|
||
if cub == "PROYECTOS":
|
||
kw["app"] = R.app_proyecto(ext) or ("Diseño" if rol == "diseno" else "Edicion")
|
||
destino = "_Revisar" if cub == "REVISAR" else destino_plantilla(tipo, cub, **kw)
|
||
dia = kw.get("dia", "")
|
||
return {"nombre": nombre, "dia": dia, "rol": rol, "que": R.ROL_QUE.get(rol, rol), "cubeta": cub,
|
||
"titulo": R.CUBETAS.get(cub, cub), "color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
|
||
"destino": destino, "crudo": [x for x in (PLANTILLAS.get(tipo, {}).get(cub, "") or "").split("/") if x],
|
||
"fecha": fecha, "por_fecha": origen, "camara": firma or "",
|
||
"app": R.app_proyecto(ext) or ""}
|
||
|
||
|
||
def renombrar_nodo(tipo, ruta, nombre, raiz=None, en_disco=False):
|
||
"""Le cambia el nombre a una carpeta del mapa: en las plantillas y, si se pide, en el disco.
|
||
|
||
`ruta` son los segmentos CRUDOS del mapa («{anio}», «{mes}», «00_Grabacion»), así que el cambio
|
||
alcanza a todas las plantillas que pasan por esa carpeta, no solo a una."""
|
||
tipo = tipo if tipo in PLANTILLAS else "contenido"
|
||
nombre = (nombre or "").strip().strip(". ")
|
||
if not nombre:
|
||
return {"error": "el nombre no puede quedar vacío"}
|
||
if any(c in nombre for c in '\\/:*?"<>|'):
|
||
return {"error": 'un nombre de carpeta no puede llevar \\ / : * ? " < > |'}
|
||
if "{" in nombre or "}" in nombre:
|
||
return {"error": "aquí va un nombre, no una variable: las variables se editan en la plantilla"}
|
||
if not ruta or "{" in ruta[-1]:
|
||
return {"error": "esa carpeta la nombra el material (el año, el mes, la pieza): no se puede cambiar a mano"}
|
||
viejo = ruta[-1]
|
||
if R.norm(viejo) == R.norm(nombre):
|
||
return {"error": "ya se llama así"}
|
||
|
||
cambiadas = []
|
||
for cub, pl in list(PLANTILLAS[tipo].items()):
|
||
segs = pl.split("/")
|
||
if len(segs) >= len(ruta) and [x.lower() for x in segs[:len(ruta)]] == [x.lower() for x in ruta]:
|
||
segs[len(ruta) - 1] = nombre
|
||
PLANTILLAS[tipo][cub] = "/".join(segs)
|
||
cambiadas.append(cub)
|
||
if not cambiadas:
|
||
return {"error": "esa carpeta ya no está en ninguna plantilla; vuelve a abrir el mapa"}
|
||
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
|
||
PLANES.clear()
|
||
|
||
movidas, errores = 0, []
|
||
if en_disco and raiz:
|
||
movidas, errores = _renombrar_en_disco(_clave(raiz), ruta, nombre)
|
||
return {"ok": True, "cubetas": cambiadas, "viejo": viejo, "nuevo": nombre,
|
||
"movidas": movidas, "errores": errores}
|
||
|
||
|
||
def _casan(segmentos, patron):
|
||
"""True si los segmentos de una carpeta real encajan con un patron de plantilla.
|
||
|
||
«{anio}» y «{mes}» solo valen por algo que de verdad parezca un ano o un mes: sin eso, «{anio}»
|
||
encajaba con cualquier carpeta del primer nivel («Efectos» salia como si fuera un ano)."""
|
||
if len(segmentos) != len(patron):
|
||
return False
|
||
for g, p in zip(segmentos, patron):
|
||
if p == "{anio}":
|
||
if not R.detectar_anio(g):
|
||
return False
|
||
elif p == "{mes}":
|
||
if not R.detectar_mes(g)[0]:
|
||
return False
|
||
elif p.startswith("{"):
|
||
continue
|
||
elif R.norm(p) != R.norm(g):
|
||
return False
|
||
return True
|
||
|
||
|
||
def carpetas_como(raiz, ruta):
|
||
"""Las carpetas que hay HOY en el disco en el sitio que describe `ruta` (segmentos crudos)."""
|
||
raiz = _clave(raiz)
|
||
con = db()
|
||
filas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
|
||
con.close()
|
||
out = []
|
||
for c in filas:
|
||
rel = os.path.relpath(c, raiz)
|
||
if rel == "." or not os.path.isdir(c):
|
||
continue
|
||
if _casan(rel.split(os.sep), ruta):
|
||
out.append(c)
|
||
return sorted(out, key=lambda x: x.lower())
|
||
|
||
|
||
def _renombrar_en_disco(raiz, ruta, nombre):
|
||
"""Renombra en el disco todas las carpetas que estaban en ese sitio del mapa. Queda en la bitácora
|
||
como un movimiento normal, así que se deshace desde Historial."""
|
||
lote = "renombrar-" + time.strftime("%Y%m%d-%H%M%S")
|
||
movidos, errores = [], []
|
||
for c in carpetas_como(raiz, ruta):
|
||
destino = os.path.join(os.path.dirname(c), nombre)
|
||
if os.path.normcase(c) == os.path.normcase(destino):
|
||
continue
|
||
try:
|
||
mover(c, destino, lote, movidos)
|
||
except Exception as ex: # noqa
|
||
errores.append(f"{os.path.relpath(c, raiz)}: {ex}")
|
||
if movidos:
|
||
actualizar_inventario(movidos)
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos),
|
||
"movimientos": len(movidos), "errores": errores})
|
||
return len(movidos), errores
|
||
|
||
|
||
# ==========================================================================
|
||
# Color de carpeta en el Explorador de Windows
|
||
# ==========================================================================
|
||
#
|
||
# Windows no tiene «color de carpeta», pero sí icono por carpeta: un desktop.ini que apunta a un .ico
|
||
# y la carpeta marcada como solo lectura para que el Explorador lo lea. La app genera un icono por
|
||
# color dentro de la propia carpeta del cliente y lo referencia con ruta relativa, así que los colores
|
||
# viajan con el disco y se ven igual desde cualquier PC que lo tenga montado.
|
||
|
||
F_READONLY, F_HIDDEN, F_SYSTEM = 0x1, 0x2, 0x4
|
||
FCSM_ICONFILE, FCS_READ, FCS_FORCEWRITE = 0x10, 0x1, 0x2
|
||
SHCNE_UPDATEDIR, SHCNF_PATHW = 0x1000, 0x0005
|
||
|
||
|
||
class _FCS(ctypes.Structure):
|
||
"""SHFOLDERCUSTOMSETTINGS: lo que hay en el desktop.ini de una carpeta, visto por el Explorador."""
|
||
_fields_ = [("dwSize", ctypes.c_uint32), ("dwMask", ctypes.c_uint32),
|
||
("pvid", ctypes.c_void_p), ("pszWebViewTemplate", ctypes.c_wchar_p),
|
||
("cchWebViewTemplate", ctypes.c_uint32), ("pszWebViewTemplateVersion", ctypes.c_wchar_p),
|
||
("pszInfoTip", ctypes.c_wchar_p), ("cchInfoTip", ctypes.c_uint32),
|
||
("pclsid", ctypes.c_void_p), ("dwFlags", ctypes.c_uint32),
|
||
("pszIconFile", ctypes.c_wchar_p), ("cchIconFile", ctypes.c_uint32),
|
||
("iIconIndex", ctypes.c_int), ("pszLogo", ctypes.c_wchar_p), ("cchLogo", ctypes.c_uint32)]
|
||
|
||
|
||
def _fcs():
|
||
f = ctypes.windll.shell32.SHGetSetFolderCustomSettings
|
||
f.argtypes = [ctypes.POINTER(_FCS), ctypes.c_wchar_p, ctypes.c_uint32]
|
||
f.restype = ctypes.c_long
|
||
return f
|
||
|
||
|
||
def _poner_icono(carpeta, icono):
|
||
"""Le pone a la carpeta ese .ico (la ruta puede ser relativa, y así los colores viajan con el disco)."""
|
||
w = _FCS()
|
||
w.dwSize = ctypes.sizeof(_FCS)
|
||
w.dwMask = FCSM_ICONFILE
|
||
w.pszIconFile = icono
|
||
w.iIconIndex = 0
|
||
try:
|
||
if _fcs()(ctypes.byref(w), carpeta, FCS_FORCEWRITE) != 0:
|
||
return False
|
||
ctypes.windll.shell32.SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, ctypes.c_wchar_p(carpeta), None)
|
||
return True
|
||
except Exception: # noqa
|
||
return False
|
||
|
||
|
||
def _icono_de(carpeta):
|
||
"""El .ico que tiene puesto la carpeta, o "" si no tiene ninguno."""
|
||
buf = ctypes.create_unicode_buffer(600)
|
||
r = _FCS()
|
||
r.dwSize = ctypes.sizeof(_FCS)
|
||
r.dwMask = FCSM_ICONFILE
|
||
r.pszIconFile = ctypes.cast(buf, ctypes.c_wchar_p)
|
||
r.cchIconFile = 600
|
||
try:
|
||
return buf.value if _fcs()(ctypes.byref(r), carpeta, FCS_READ) == 0 else ""
|
||
except Exception: # noqa
|
||
return ""
|
||
|
||
|
||
def _ini_nuestro(ruta):
|
||
"""Un desktop.ini es de la app si apunta a un icono de su propia carpeta."""
|
||
try:
|
||
with open(ruta, "rb") as fh:
|
||
return R.CARPETA_APP.encode() in fh.read(1024).lower()
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def _atributos(ruta, poner=0, quitar=0):
|
||
"""Pone o quita atributos y devuelve si de verdad quedaron puestos.
|
||
|
||
En un NAS por SMB, SetFileAttributesW dice que sí y el atributo no queda: medido el 16-sep-2026,
|
||
Z: acepta SYSTEM pero no READONLY, y W: no acepta ninguno de los dos. Por eso se relee."""
|
||
try:
|
||
a = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
|
||
if a == -1:
|
||
return False
|
||
ctypes.windll.kernel32.SetFileAttributesW(str(ruta), (a | poner) & ~quitar)
|
||
if not poner:
|
||
return True
|
||
b = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
|
||
return b != -1 and bool(b & poner)
|
||
except Exception: # noqa
|
||
return False
|
||
|
||
|
||
def _marcar_carpeta(carpeta):
|
||
"""El Explorador solo lee desktop.ini si la carpeta está marcada como solo lectura O como sistema.
|
||
Vale cualquiera de las dos: se intentan las dos y basta con que una quede."""
|
||
return (_atributos(carpeta, poner=F_READONLY) or _atributos(carpeta, poner=F_SYSTEM))
|
||
|
||
|
||
def _png_carpeta(n, rgb):
|
||
"""Una carpeta del color pedido, n×n RGBA, con la pestaña un punto más oscura."""
|
||
u = n / 32.0
|
||
base = rgb
|
||
tab = tuple(int(c * 0.78) for c in rgb)
|
||
|
||
def rect(px, py, x0, y0, x1, y1, r):
|
||
if not (x0 <= px <= x1 and y0 <= py <= y1):
|
||
return False
|
||
cx = min(max(px, x0 + r), x1 - r)
|
||
cy = min(max(py, y0 + r), y1 - r)
|
||
return (px - cx) ** 2 + (py - cy) ** 2 <= r * r
|
||
|
||
filas = []
|
||
for y in range(n):
|
||
fila = bytearray()
|
||
for x in range(n):
|
||
dentro_b = dentro_t = 0
|
||
for sx, sy in ((.25, .25), (.75, .25), (.25, .75), (.75, .75)):
|
||
px, py = (x + sx) / u, (y + sy) / u
|
||
if rect(px, py, 2, 8.5, 30, 27, 2.2):
|
||
dentro_b += 1
|
||
elif rect(px, py, 2, 5, 14.5, 11, 1.4):
|
||
dentro_t += 1
|
||
if dentro_b:
|
||
a, col = dentro_b / 4, base
|
||
elif dentro_t:
|
||
a, col = dentro_t / 4, tab
|
||
else:
|
||
fila += bytes((0, 0, 0, 0))
|
||
continue
|
||
fila += bytes((col[0], col[1], col[2], int(255 * a)))
|
||
filas.append(bytes(fila))
|
||
raw = b"".join(b"\x00" + f for f in filas)
|
||
|
||
def chunk(t, d):
|
||
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
|
||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0))
|
||
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
|
||
|
||
|
||
def _ico_carpeta(hexcol):
|
||
rgb = tuple(int(hexcol.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
|
||
tam = (256, 48, 32, 16)
|
||
imgs = [_png_carpeta(t, rgb) for t in tam]
|
||
cab = struct.pack("<HHH", 0, 1, len(tam))
|
||
off = 6 + 16 * len(tam)
|
||
entradas = b""
|
||
for t, im in zip(tam, imgs):
|
||
entradas += struct.pack("<BBBBHHII", t % 256, t % 256, 0, 0, 1, 32, len(im), off)
|
||
off += len(im)
|
||
return cab + entradas + b"".join(imgs)
|
||
|
||
|
||
def _dir_iconos(raiz):
|
||
return os.path.join(raiz, R.CARPETA_APP, "iconos")
|
||
|
||
|
||
def _esta_pintada(raiz):
|
||
return os.path.isdir(_dir_iconos(raiz))
|
||
|
||
|
||
def _nodos_pintables(nodo, out=None):
|
||
"""(segmentos crudos, color) de cada carpeta del mapa que tiene color, las más concretas primero:
|
||
«{anio}/{mes}/{nombre}» también encaja con «00_Grabacion», y ahí manda la que tiene nombre fijo."""
|
||
out = [] if out is None else out
|
||
if nodo.get("crudo") and nodo.get("color"):
|
||
out.append((nodo["crudo"], nodo["color"]))
|
||
for h in nodo.get("hijos", []):
|
||
_nodos_pintables(h, out)
|
||
out.sort(key=lambda x: -sum(0 if seg.startswith("{") else 1 for seg in x[0]))
|
||
return out
|
||
|
||
|
||
def _carpetas_a_pintar(raiz):
|
||
"""Cada carpeta real del cliente con el color de lo que guarda."""
|
||
mapa = mapa_estructura((raiz_cfg(raiz) or {}).get("tipo", "contenido"), raiz)
|
||
patrones = _nodos_pintables(mapa["arbol"])
|
||
con = db()
|
||
reales = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
|
||
con.close()
|
||
out = {}
|
||
for c in reales:
|
||
rel = os.path.relpath(c, raiz)
|
||
if rel == "." or rel.split(os.sep)[0].lower() == R.CARPETA_APP:
|
||
continue
|
||
segs = rel.split(os.sep)
|
||
for crudo, color in patrones:
|
||
if _casan(segs, crudo):
|
||
out[c] = color
|
||
break
|
||
# lo que cuelga de una carpeta con color es más de lo mismo («URSA_BRAW» dentro de «00_Grabacion»):
|
||
# hereda su color, y así en el Explorador una rama entera se ve de un color
|
||
for c in sorted(reales, key=lambda x: len(x)):
|
||
if c in out or os.path.relpath(c, raiz) == ".":
|
||
continue
|
||
padre = os.path.dirname(c)
|
||
while padre.lower().startswith(raiz.lower()) and padre.lower() != raiz.lower():
|
||
if padre in out:
|
||
out[c] = out[padre]
|
||
break
|
||
padre = os.path.dirname(padre)
|
||
return out
|
||
|
||
|
||
def pintar_carpetas(raiz):
|
||
"""Le pone a cada carpeta el icono del color de lo que guarda."""
|
||
raiz = _clave(raiz)
|
||
if not os.path.isdir(raiz):
|
||
return {"error": "la carpeta no está conectada"}
|
||
objetivo = _carpetas_a_pintar(raiz)
|
||
if not objetivo:
|
||
return {"error": "no hay ninguna carpeta reconocida todavía: escanea y ordena primero"}
|
||
iconos = _dir_iconos(raiz)
|
||
try:
|
||
os.makedirs(iconos, exist_ok=True)
|
||
_atributos(os.path.join(raiz, R.CARPETA_APP), poner=F_HIDDEN)
|
||
except OSError as ex:
|
||
return {"error": f"no se pudo crear la carpeta de iconos: {ex}"}
|
||
for color in set(objetivo.values()):
|
||
ico = os.path.join(iconos, color.lstrip("#").lower() + ".ico")
|
||
if not os.path.exists(ico):
|
||
with open(ico, "wb") as fh:
|
||
fh.write(_ico_carpeta(color))
|
||
pintadas, sin_marca, errores = 0, 0, []
|
||
for carpeta, color in sorted(objetivo.items()):
|
||
try:
|
||
rel = os.path.join(os.path.relpath(iconos, carpeta), color.lstrip("#").lower() + ".ico")
|
||
_atributos(os.path.join(carpeta, "desktop.ini"), quitar=F_READONLY)
|
||
if not _poner_icono(carpeta, rel):
|
||
sin_marca += 1
|
||
continue
|
||
# la API marca la carpeta ella sola, pero algunos discos de red no dejan: se comprueba
|
||
if _icono_de(carpeta).lower() == rel.lower() and _marcar_carpeta(carpeta):
|
||
pintadas += 1
|
||
else:
|
||
sin_marca += 1
|
||
except OSError as ex:
|
||
if len(errores) < 5:
|
||
errores.append(f"{os.path.relpath(carpeta, raiz)}: {ex}")
|
||
if not pintadas and sin_marca:
|
||
# el disco no deja marcar las carpetas: el desktop.ini se quedaría ahí sin hacer nada
|
||
despintar_carpetas(raiz)
|
||
return {"error": "este disco no deja marcar las carpetas, y sin esa marca el Explorador ni mira el "
|
||
"color. Pasa en algunos NAS y discos de red. No se ha dejado nada escrito.",
|
||
"pintadas": 0, "errores": []}
|
||
try:
|
||
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None) # SHCNE_ASSOCCHANGED
|
||
except Exception: # noqa
|
||
pass
|
||
bitacora({"lote": "colores-" + time.strftime("%Y%m%d-%H%M%S"), "op": "colores", "raiz": raiz,
|
||
"pintadas": pintadas, "errores": errores})
|
||
return {"pintadas": pintadas, "sin_marca": sin_marca, "colores": len(set(objetivo.values())),
|
||
"errores": errores}
|
||
|
||
|
||
def despintar_carpetas(raiz):
|
||
"""Quita los colores: borra los desktop.ini que puso la app y su carpeta de iconos."""
|
||
raiz = _clave(raiz)
|
||
quitadas, errores = 0, []
|
||
for base, dirs, files in os.walk(raiz):
|
||
if os.path.basename(base).lower() == R.CARPETA_APP:
|
||
dirs[:] = []
|
||
continue
|
||
for f in files:
|
||
if f.lower() != "desktop.ini":
|
||
continue
|
||
ini = os.path.join(base, f)
|
||
if not _ini_nuestro(ini):
|
||
continue
|
||
try:
|
||
_atributos(ini, quitar=F_READONLY | F_HIDDEN | F_SYSTEM)
|
||
os.remove(ini)
|
||
_atributos(base, quitar=F_READONLY)
|
||
quitadas += 1
|
||
except OSError as ex:
|
||
if len(errores) < 5:
|
||
errores.append(f"{os.path.relpath(base, raiz)}: {ex}")
|
||
try:
|
||
shutil.rmtree(os.path.join(raiz, R.CARPETA_APP), ignore_errors=True)
|
||
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None)
|
||
except Exception: # noqa
|
||
pass
|
||
return {"quitadas": quitadas, "errores": errores}
|
||
|
||
|
||
# ==========================================================================
|
||
# Material nuevo: pieza nueva, bandeja de entrada y orden automático
|
||
# ==========================================================================
|
||
|
||
_RX_NOMBRE_MALO = re.compile(r'[\\/:*?"<>|]')
|
||
_SERIE = [0]
|
||
|
||
|
||
def _subcarpetas_pieza():
|
||
"""Las subcarpetas con las que nace una pieza. Se cambian en config.json («pieza» › «subcarpetas»)."""
|
||
subs = (CONFIG.get("pieza") or {}).get("subcarpetas")
|
||
if not isinstance(subs, list):
|
||
subs = CONFIG_DEFECTO["pieza"]["subcarpetas"]
|
||
return [s.strip() for s in subs if isinstance(s, str) and s.strip() and not _RX_NOMBRE_MALO.search(s)][:20]
|
||
|
||
|
||
def _sitio_pieza(raiz, tipo, titulo, anio, mes):
|
||
"""(carpeta donde va, nombre que le toca, la que ya existe con ese título o None)."""
|
||
pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get("PIEZA") or "{anio}/{mes}/{nombre}"
|
||
destino = os.path.join(raiz, destino_plantilla(tipo, "PIEZA", anio=anio, mes=mes, nombre=titulo, sub=titulo))
|
||
padre = os.path.dirname(destino)
|
||
if "{mes}" not in pl: # campañas y proyectos: sin número
|
||
return padre, os.path.basename(destino), destino if os.path.isdir(destino) else None
|
||
numeros, ya = [], None
|
||
try:
|
||
with os.scandir(padre) as it:
|
||
for e in it:
|
||
if not e.is_dir(follow_symlinks=False):
|
||
continue
|
||
p = R.detectar_pieza(e.name)
|
||
if p:
|
||
numeros.append(p[0])
|
||
if R.norm(p[1] if p else e.name) == R.norm(titulo):
|
||
ya = e.path
|
||
except OSError:
|
||
pass # el mes todavía no existe: será la primera
|
||
return padre, R.nombre_pieza(max(numeros, default=0) + 1, titulo), ya
|
||
|
||
|
||
def nueva_pieza(raiz, titulo, anio=None, mes=None, crear=True):
|
||
"""Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas.
|
||
|
||
Las piezas se venían creando a mano, y de ahí salían dos «01» en el mismo mes, «03 - Título» junto a
|
||
«03- Título» y subcarpetas con el nombre a medio escribir («portasda», «Imagnes»). Con `crear=False`
|
||
solo dice cómo quedaría."""
|
||
raiz = _clave(raiz)
|
||
r = raiz_cfg(raiz)
|
||
if not r:
|
||
return {"error": "carpeta no registrada"}
|
||
titulo = limpiar_nombre(re.sub(r"\s+", " ", titulo or "")).strip(". ")
|
||
p = R.detectar_pieza(titulo)
|
||
if p:
|
||
titulo = limpiar_nombre(p[1]) # «03- Botox»: el número lo pone la app
|
||
if not titulo:
|
||
return {"error": "ponle un título a la pieza"}
|
||
if _RX_NOMBRE_MALO.search(titulo):
|
||
return {"error": 'el título no puede llevar \\ / : * ? " < > |'}
|
||
if len(titulo) > 80:
|
||
return {"error": "el título es demasiado largo: 80 letras como mucho"}
|
||
hoy = datetime.date.today()
|
||
try:
|
||
anio, mes = int(anio or hoy.year), int(mes or hoy.month)
|
||
except (TypeError, ValueError):
|
||
return {"error": "el año o el mes no valen"}
|
||
if not (2000 <= anio <= 2100 and 1 <= mes <= 12):
|
||
return {"error": "el año o el mes no valen"}
|
||
padre, nombre, ya = _sitio_pieza(raiz, r.get("tipo", "contenido"), titulo, anio, mes)
|
||
destino = os.path.join(padre, nombre)
|
||
subs = _subcarpetas_pieza()
|
||
out = {"raiz": raiz, "ruta": destino, "rel": os.path.relpath(destino, raiz), "nombre": nombre,
|
||
"subcarpetas": subs, "existe": os.path.relpath(ya, raiz) if ya else ""}
|
||
if not crear:
|
||
return out
|
||
if ya:
|
||
return {**out, "error": f"esa pieza ya existe: «{os.path.relpath(ya, raiz)}»"}
|
||
if not os.path.isdir(raiz):
|
||
return {**out, "error": "la carpeta del cliente no está conectada"}
|
||
_SERIE[0] += 1
|
||
lote = "pieza-" + time.strftime("%Y%m%d-%H%M%S") + f"-{_SERIE[0]}" # dos seguidas no comparten lote
|
||
faltan, d = [], destino # las de encima que aún no existen (el año, el mes)
|
||
while not os.path.isdir(d) and dentro_de(raiz, d) and d.lower() != raiz.lower():
|
||
faltan.append(d)
|
||
d = os.path.dirname(d)
|
||
creadas = []
|
||
try:
|
||
for d in list(reversed(faltan)) + [os.path.join(destino, s) for s in subs]:
|
||
os.mkdir(d)
|
||
bitacora({"lote": lote, "op": "mkdir", "de": d})
|
||
creadas.append(d)
|
||
except OSError as ex:
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 0, "movimientos": len(creadas), "errores": [str(ex)]})
|
||
return {**out, "error": f"no se pudo crear: {ex.strerror or ex}"}
|
||
con = db()
|
||
with con:
|
||
for d in creadas:
|
||
con.execute("INSERT OR IGNORE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)", (d, raiz, os.path.basename(d)))
|
||
con.close()
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 1, "movimientos": len(creadas), "errores": []})
|
||
olvidar_plan(raiz)
|
||
return {**out, "ok": True, "lote": lote, "creadas": len(creadas)}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# La bandeja de entrada
|
||
# --------------------------------------------------------------------------
|
||
#
|
||
# El desorden nace el día que se vuelca la tarjeta: se copia donde se puede, con el nombre que sale. La
|
||
# bandeja es una carpeta «_Entrada» dentro de la del cliente; lo que se deje ahí —a mano, o trayéndolo
|
||
# desde la tarjeta con la copia comprobada— el plan lo abre siempre y lo reparte por rodaje y cámara.
|
||
|
||
TRAER = {"estado": "", "raiz": "", "origen": "", "destino": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0,
|
||
"actual": "", "copiados": 0, "saltados": 0, "errores": [], "cancelar": False, "inicio": 0, "fin": 0,
|
||
"ordenado": None}
|
||
_NO_TARJETA = {"system volume information", "$recycle.bin", ".trashes", ".spotlight-v100", ".fseventsd",
|
||
".temporaryitems", "lost.dir", ".trash-1000"}
|
||
|
||
|
||
def _ruta_entrada(raiz):
|
||
return os.path.join(_clave(raiz), R.ENTRADA)
|
||
|
||
|
||
def entrada(raiz):
|
||
"""Qué hay esperando en la bandeja. Mira el disco y no el inventario, que puede ser de ayer."""
|
||
raiz = _clave(raiz)
|
||
base = _ruta_entrada(raiz)
|
||
n = b = 0
|
||
ejemplos = []
|
||
if os.path.isdir(base):
|
||
for d, _dirs, fs in os.walk(base):
|
||
for f in fs:
|
||
if R.es_basura_archivo(f) or f.endswith(".copiando"):
|
||
continue
|
||
try:
|
||
b += os.path.getsize(os.path.join(d, f))
|
||
except OSError:
|
||
continue
|
||
n += 1
|
||
if len(ejemplos) < 5:
|
||
ejemplos.append(os.path.relpath(os.path.join(d, f), base))
|
||
return {"raiz": raiz, "carpeta": base, "existe": os.path.isdir(base), "n": n, "bytes": b, "ejemplos": ejemplos,
|
||
"copia": dict(TRAER) if TRAER.get("raiz") == raiz and TRAER.get("estado") else None}
|
||
|
||
|
||
def abrir_entrada(raiz):
|
||
"""Crea la bandeja si no existe y la abre en el Explorador, para soltar material dentro."""
|
||
raiz = _clave(raiz)
|
||
if not raiz_cfg(raiz) or not os.path.isdir(raiz):
|
||
return {"error": "la carpeta del cliente no está conectada"}
|
||
base = _ruta_entrada(raiz)
|
||
os.makedirs(base, exist_ok=True)
|
||
try:
|
||
os.startfile(base)
|
||
except OSError:
|
||
pass
|
||
return {"ok": True, "carpeta": base}
|
||
|
||
|
||
def _podar(base, lote):
|
||
"""Quita de la bandeja las carpetas que se quedaron sin un solo archivo: el esqueleto de una tarjeta
|
||
ya repartida (PRIVATE, M4ROOT, CLIP…). Queda apuntado en el mismo lote, así que «Deshacer» las
|
||
vuelve a crear antes de devolver los archivos."""
|
||
quitadas = []
|
||
for d, _dirs, _fs in os.walk(base, topdown=False):
|
||
if os.path.normcase(d) == os.path.normcase(base):
|
||
continue
|
||
try:
|
||
if not os.listdir(d):
|
||
os.rmdir(d)
|
||
bitacora({"lote": lote, "op": "rmdir", "de": d})
|
||
quitadas.append(d)
|
||
except OSError:
|
||
pass
|
||
if quitadas:
|
||
con = db()
|
||
with con:
|
||
for d in quitadas:
|
||
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
|
||
con.close()
|
||
|
||
|
||
def ordenar_entrada(raiz):
|
||
"""Lee la carpeta otra vez y aplica, del plan, lo que sale de la bandeja y va marcado."""
|
||
raiz = _clave(raiz)
|
||
r = raiz_cfg(raiz)
|
||
if not r:
|
||
return {"error": "carpeta no registrada"}
|
||
if asistente.ESTADO["activo"] or (APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]):
|
||
return {"error": "ya hay algo en marcha en esta carpeta"}
|
||
escanear(raiz)
|
||
prog = PROGRESO.get(raiz) or {}
|
||
if prog.get("estado") != "listo":
|
||
return {"error": "no se pudo leer la carpeta: " + (prog.get("error") or "¿está conectada?")}
|
||
plan = Plan(r).correr()
|
||
base = _ruta_entrada(raiz)
|
||
filas, dudosos = [], 0
|
||
for f in plan["filas"]:
|
||
if f.get("archivos"):
|
||
mios = [a for a in f["archivos"] if dentro_de(base, a)]
|
||
if not mios:
|
||
continue
|
||
if len(mios) < len(f["archivos"]): # la fila de «basura repartida por la carpeta»: solo lo de aquí
|
||
f = dict(f, archivos=mios)
|
||
if f.get("destinos_archivo"):
|
||
f["destinos_archivo"] = {a: d for a, d in f["destinos_archivo"].items() if a in set(mios)}
|
||
elif not dentro_de(base, f["origen"]):
|
||
continue
|
||
if f["marcado"]:
|
||
filas.append(f)
|
||
else:
|
||
dudosos += 1
|
||
res = aplicar(raiz, filas) if filas else {"lote": None, "movimientos": 0, "errores": []}
|
||
PLANES.pop(raiz, None)
|
||
try:
|
||
os.makedirs(base, exist_ok=True) # la bandeja se queda, vacía, para la próxima vez
|
||
except OSError:
|
||
pass
|
||
if res.get("lote"):
|
||
_podar(base, res["lote"])
|
||
return {"ok": True, "lote": res.get("lote"), "movimientos": res.get("movimientos", 0), "errores": res.get("errores", []),
|
||
"quedan": entrada(raiz)["n"], "dudosos": dudosos,
|
||
"avisos": [a for a in plan["avisos"] if R.ENTRADA in a]}
|
||
|
||
|
||
def _archivos_de(origen):
|
||
"""Los archivos de una tarjeta o de una carpeta cualquiera, sin lo que es del sistema."""
|
||
for d, dirs, fs in os.walk(origen):
|
||
dirs[:] = sorted(x for x in dirs if x.lower() not in _NO_TARJETA)
|
||
for f in sorted(fs):
|
||
if f.lower() in ("thumbs.db", "desktop.ini", ".ds_store") or f.startswith("._"):
|
||
continue
|
||
yield os.path.join(d, f)
|
||
|
||
|
||
def plan_traer(raiz, origen, nombre=""):
|
||
"""Qué se copiaría de una tarjeta a la bandeja, y si cabe. No copia nada."""
|
||
raiz = _clave(raiz)
|
||
if not raiz_cfg(raiz):
|
||
return {"error": "carpeta no registrada"}
|
||
if not os.path.isdir(raiz):
|
||
return {"error": "la carpeta del cliente no está conectada"}
|
||
origen = os.path.normpath(origen or "")
|
||
if not origen or not os.path.isdir(origen):
|
||
return {"error": "elige la tarjeta o la carpeta de la que traer el material"}
|
||
if dentro_de(raiz, origen) or dentro_de(origen, raiz):
|
||
return {"error": "eso ya está dentro de la carpeta del cliente: no hace falta traerlo"}
|
||
if _raiz_de(origen):
|
||
return {"error": "esa carpeta es de otro cliente registrado; desde aquí solo se trae material de fuera"}
|
||
nombre = limpiar_nombre(re.sub(r"\s+", " ", nombre or "")).strip(". ")
|
||
if _RX_NOMBRE_MALO.search(nombre):
|
||
return {"error": 'el nombre no puede llevar \\ / : * ? " < > |'}
|
||
# sin nombre, la carpeta se llama «Tarjeta <fecha>»: un nombre que el plan no toma por el de una sesión
|
||
carpeta = nombre or ("Tarjeta " + time.strftime("%Y-%m-%d %H%M"))
|
||
n = b = 0
|
||
for a in _archivos_de(origen):
|
||
try:
|
||
b += os.path.getsize(a)
|
||
n += 1
|
||
except OSError:
|
||
pass
|
||
if not n:
|
||
return {"error": "ahí no hay ningún archivo"}
|
||
try:
|
||
libre = shutil.disk_usage(raiz).free
|
||
except OSError:
|
||
libre = None
|
||
out = {"raiz": raiz, "origen": origen, "destino": os.path.join(_ruta_entrada(raiz), carpeta), "carpeta": carpeta,
|
||
"n": n, "bytes": b, "libre": libre}
|
||
if libre is not None and b > libre:
|
||
out["error"] = f"no cabe: son {_gb(b)} y en el disco del cliente quedan {_gb(libre)}"
|
||
return out
|
||
|
||
|
||
def _copiar_comprobando(origen, destino, estado):
|
||
"""Copia un archivo y vuelve a leer lo copiado para comprobar que quedó igual. True si copió, False
|
||
si ya estaba. Del origen solo se lee: ni se mueve ni se borra."""
|
||
st = os.stat(origen)
|
||
try:
|
||
sd = os.stat(destino)
|
||
if sd.st_size == st.st_size and abs(sd.st_mtime - st.st_mtime) < 2:
|
||
estado["bytes"] += st.st_size
|
||
return False # ya se trajo antes: una copia a medias se retoma
|
||
raise FileExistsError("en la bandeja ya hay otro archivo distinto con ese nombre")
|
||
except FileNotFoundError:
|
||
pass
|
||
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
||
tmp = destino + ".copiando"
|
||
antes = estado["bytes"]
|
||
try:
|
||
h1 = hashlib.blake2b(digest_size=16)
|
||
with open(origen, "rb") as a, open(tmp, "wb") as b:
|
||
while True:
|
||
if estado.get("cancelar"):
|
||
raise InterruptedError("cancelado")
|
||
trozo = a.read(4 << 20)
|
||
if not trozo:
|
||
break
|
||
h1.update(trozo)
|
||
b.write(trozo)
|
||
estado["bytes"] += len(trozo)
|
||
b.flush()
|
||
os.fsync(b.fileno())
|
||
h2 = hashlib.blake2b(digest_size=16)
|
||
with open(tmp, "rb") as b:
|
||
while True:
|
||
if estado.get("cancelar"):
|
||
raise InterruptedError("cancelado")
|
||
trozo = b.read(4 << 20)
|
||
if not trozo:
|
||
break
|
||
h2.update(trozo)
|
||
if h1.digest() != h2.digest() or os.path.getsize(tmp) != st.st_size:
|
||
raise OSError("lo copiado no quedó igual que el original")
|
||
os.replace(tmp, destino)
|
||
shutil.copystat(origen, destino) # la fecha del archivo es la del rodaje: se conserva
|
||
return h1.hexdigest()
|
||
except BaseException:
|
||
estado["bytes"] = antes
|
||
try:
|
||
os.remove(tmp)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
def traer(raiz, origen, nombre="", ordenar=True, en_hilo=False):
|
||
"""Trae a la bandeja lo que hay en una tarjeta (o en cualquier carpeta de fuera), comprobando cada
|
||
archivo, y al terminar lo reparte. El origen no se toca."""
|
||
p = plan_traer(raiz, origen, nombre)
|
||
if p.get("error"):
|
||
return p
|
||
if TRAER.get("estado") in ("copiando", "ordenando"):
|
||
return {"error": "ya se está trayendo material"}
|
||
TRAER.update(estado="copiando", raiz=p["raiz"], origen=p["origen"], destino=p["destino"], hechos=0, total=p["n"],
|
||
bytes=0, bytes_total=p["bytes"], actual="", copiados=0, saltados=0, errores=[], cancelar=False,
|
||
inicio=time.time(), fin=0, ordenado=None)
|
||
if en_hilo:
|
||
threading.Thread(target=_traer, args=(p, ordenar), daemon=True).start()
|
||
return {"lanzado": True, **p}
|
||
return _traer(p, ordenar)
|
||
|
||
|
||
def _traer(p, ordenar):
|
||
raiz = p["raiz"]
|
||
lote = "entrada-" + time.strftime("%Y%m%d-%H%M%S")
|
||
comprobados = []
|
||
try:
|
||
for a in _archivos_de(p["origen"]):
|
||
if TRAER["cancelar"]:
|
||
break
|
||
rel = os.path.relpath(a, p["origen"])
|
||
TRAER["actual"] = rel
|
||
try:
|
||
h = _copiar_comprobando(a, os.path.join(p["destino"], rel), TRAER)
|
||
if h:
|
||
TRAER["copiados"] += 1
|
||
comprobados.append((rel, os.path.getsize(a), h))
|
||
else:
|
||
TRAER["saltados"] += 1
|
||
except InterruptedError:
|
||
break
|
||
except Exception as ex: # noqa
|
||
TRAER["errores"].append(f"{rel}: {getattr(ex, 'strerror', None) or ex}")
|
||
TRAER["hechos"] += 1
|
||
# la lista de lo que se copió y con qué huella queda en la carpeta de la app dentro de la del cliente
|
||
if comprobados:
|
||
try:
|
||
d = os.path.join(raiz, R.CARPETA_APP, "entradas")
|
||
os.makedirs(d, exist_ok=True)
|
||
with open(os.path.join(d, lote + ".csv"), "w", encoding="utf-8-sig", newline="") as fh:
|
||
fh.write("archivo;bytes;huella\r\n")
|
||
for rel, tam, h in comprobados:
|
||
fh.write(f"{rel};{tam};{h}\r\n")
|
||
except OSError:
|
||
pass
|
||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": TRAER["hechos"], "movimientos": TRAER["copiados"],
|
||
"errores": list(TRAER["errores"]), "origen": p["origen"], "destino": p["destino"]})
|
||
if TRAER["cancelar"]:
|
||
TRAER["estado"] = "cancelado"
|
||
elif TRAER["errores"]:
|
||
TRAER["estado"] = "error"
|
||
else:
|
||
if ordenar:
|
||
TRAER.update(estado="ordenando", actual="repartiendo por rodaje y cámara")
|
||
TRAER["ordenado"] = ordenar_entrada(raiz)
|
||
TRAER["estado"] = "listo"
|
||
except Exception as ex: # noqa
|
||
TRAER["errores"].append(str(ex))
|
||
TRAER["estado"] = "error"
|
||
TRAER.update(fin=time.time(), actual="")
|
||
return dict(TRAER)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Orden automático: todas las carpetas, lo seguro, sin que nadie pulse nada
|
||
# --------------------------------------------------------------------------
|
||
|
||
AUTOMATICO_PATH = os.path.join(DATOS, "automatico.json")
|
||
AUTO = {"activo": False, "raiz": "", "hechas": 0, "total": 0, "inicio": 0}
|
||
TAREA_WINDOWS = "OrganiLAB - orden automatico"
|
||
|
||
|
||
def automatico_una(raiz, limite):
|
||
"""Pone al día una carpeta sin preguntar: la lee y hace solo lo que el plan da por seguro. Si no se
|
||
puede leer entera, o si el plan quiere mover demasiado para hacerlo sin que alguien lo mire, no toca
|
||
nada y lo dice."""
|
||
raiz = _clave(raiz)
|
||
r = raiz_cfg(raiz) or {}
|
||
out = {"raiz": raiz, "cliente": r.get("cliente", ""), "ts": time.time(), "estado": "", "detalle": "",
|
||
"movimientos": 0, "lotes": [], "dudosos": 0, "avisos": [], "errores": []}
|
||
if not os.path.isdir(raiz):
|
||
out.update(estado="sin conexion", detalle="la carpeta no está conectada")
|
||
return out
|
||
escanear(raiz)
|
||
prog = PROGRESO.get(raiz) or {}
|
||
if prog.get("estado") != "listo" or prog.get("fallos"):
|
||
out.update(estado="sin leer", detalle=prog.get("error") or "no se pudo leer entera")
|
||
return out
|
||
plan = Plan(r).correr()
|
||
marcadas = [f for f in plan["filas"] if f["marcado"]]
|
||
n = sum(f["n"] or 0 for f in marcadas)
|
||
if n > limite:
|
||
out.update(estado="demasiado", dudosos=sum(1 for f in plan["filas"] if not f["marcado"]), avisos=plan["avisos"],
|
||
detalle=f"el plan mueve {_plural(n, 'archivo', 'archivos')}: es mucho para hacerlo sin que lo mires")
|
||
return out
|
||
dec = {p["clave"]: p["defecto"] for p in asistente.pasos(asistente.diagnostico(raiz), plan["filas"])}
|
||
dec.update(convertir="no", respaldo="no", carpetas="seguras")
|
||
res = asistente.ejecutar(raiz, dec, plan["filas"])
|
||
out["movimientos"] = (res.get("movimientos") or 0) + (res.get("stock") or 0)
|
||
out["lotes"] = res.get("lotes") or []
|
||
out["errores"] = (res.get("errores") or [])[:20]
|
||
if asistente.ESTADO.get("error"):
|
||
out["errores"].append(asistente.ESTADO["error"])
|
||
despues = plan_en_marcha(raiz) or Plan(r).correr()
|
||
out["dudosos"] = sum(1 for f in despues["filas"] if not f["marcado"])
|
||
out["avisos"] = despues["avisos"][:20]
|
||
out["estado"] = "con errores" if out["errores"] else "con dudas" if out["dudosos"] else "al dia"
|
||
PLANES[despues["raiz"]] = despues # se queda: así Inicio dice por la mañana qué quedó, sin pedir que se calcule
|
||
return out
|
||
|
||
|
||
def automatico(en_hilo=False, origen="a mano"):
|
||
"""Todas las carpetas registradas, una tras otra. Deja el resumen en datos\\automatico.json."""
|
||
ocupado = en_marcha() or (COLA["activa"] and "hay una conversión en marcha") or \
|
||
(TRAER.get("estado") in ("copiando", "ordenando") and "se está trayendo material")
|
||
if AUTO["activo"] or ocupado:
|
||
return {"error": "ahora no: " + (ocupado or "el orden automático ya está en marcha")}
|
||
raices = [_clave(r["ruta"]) for r in CONFIG.get("raices", [])]
|
||
AUTO.update(activo=True, raiz="", hechas=0, total=len(raices), inicio=time.time())
|
||
if en_hilo:
|
||
threading.Thread(target=_automatico, args=(raices, origen, False), daemon=True).start()
|
||
return {"lanzado": True, "carpetas": len(raices)}
|
||
return _automatico(raices, origen, True)
|
||
|
||
|
||
def _automatico(raices, origen, sincrono=False):
|
||
limite = int((CONFIG.get("automatico") or {}).get("limite") or 3000)
|
||
resumen = {"ts": time.time(), "origen": origen, "version": VERSION, "carpetas": []}
|
||
try:
|
||
for raiz in raices:
|
||
AUTO["raiz"] = raiz
|
||
try:
|
||
resumen["carpetas"].append(automatico_una(raiz, limite))
|
||
except Exception as ex: # noqa
|
||
resumen["carpetas"].append({"raiz": raiz, "cliente": (raiz_cfg(raiz) or {}).get("cliente", ""), "ts": time.time(),
|
||
"estado": "con errores", "detalle": repr(ex), "movimientos": 0, "lotes": [],
|
||
"dudosos": 0, "avisos": [], "errores": [repr(ex)]})
|
||
AUTO["hechas"] += 1
|
||
try:
|
||
aligerar_automatico(resumen, sincrono=sincrono)
|
||
except Exception as ex: # noqa
|
||
resumen["aligerar"] = {"error": repr(ex)}
|
||
resumen["seg"] = round(time.time() - resumen["ts"], 1)
|
||
guardar_json(AUTOMATICO_PATH, resumen)
|
||
finally:
|
||
AUTO.update(activo=False, raiz="")
|
||
return resumen
|
||
|
||
|
||
def aligerar_automatico(resumen, sincrono=False):
|
||
"""Con optimizar.aligerar activado, la pasada mete en cola lo de más de N meses, de más pesado a menos,
|
||
hasta el tope de GB por noche, en todas las carpetas conectadas. Convierte y verifica; no reemplaza
|
||
(salvo que esté puesto auto_reemplazar). Si el programa corre sin ventana (la tarea de Windows), la cola
|
||
va aquí mismo, en este proceso; si no, arranca la cola de siempre."""
|
||
a = _opt("aligerar") or {}
|
||
if not a.get("activo"):
|
||
return
|
||
o = CONFIG["optimizar"]
|
||
meses, tope = int(a.get("meses") or 6), float(a.get("gb_noche") or 100) * 1e9
|
||
total_b, por_raiz = 0, {}
|
||
for r in CONFIG.get("raices", []):
|
||
raiz = _clave(r["ruta"])
|
||
if not os.path.isdir(raiz):
|
||
continue
|
||
AUTO["raiz"] = raiz
|
||
analizar(raiz, o["min_mb"]) # lee con ffprobe lo que falte, aquí mismo
|
||
for c in sorted(candidatos(raiz, meses, o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit")), key=lambda c: -c["bytes"]):
|
||
if c["excluido"] or c["en_cola"] or total_b + c["bytes"] > tope:
|
||
continue
|
||
total_b += c["bytes"]
|
||
por_raiz.setdefault(raiz, []).append(c["ruta"])
|
||
n = 0
|
||
for raiz, rutas in por_raiz.items():
|
||
n += len(encolar(rutas, o["perfil"], o.get("chroma", "420"), o.get("color", "rec709"), raiz, o.get("formato", "h265_10bit")) or [])
|
||
resumen["aligerar"] = {"clips": n, "bytes": total_b, "meses": meses, "tope_gb": a.get("gb_noche") or 100}
|
||
if not n:
|
||
return
|
||
if sincrono:
|
||
with _COLA_LOCK:
|
||
COLA.update(activa=True, cancelar=False, hechos=0, inicio=time.time(), ritmo=[], hechos_ids=[], esperando="")
|
||
bucle_cola()
|
||
else:
|
||
iniciar_cola()
|
||
|
||
|
||
def estado_automatico():
|
||
c = CONFIG.get("automatico") or {}
|
||
return {"activo": bool(c.get("activo")), "hora": c.get("hora") or "02:00", "limite": int(c.get("limite") or 3000),
|
||
"en_marcha": dict(AUTO), "ultimo": cargar_json(AUTOMATICO_PATH, None)}
|
||
|
||
|
||
def _orden_de_la_tarea():
|
||
"""Lo que tiene que lanzar Windows: este mismo programa con la orden «automatico»."""
|
||
import instalar
|
||
exe, args = instalar.orden(BASE)
|
||
return f'"{exe}" {args} automatico' if args else f'"{exe}" automatico'
|
||
|
||
|
||
def programar_automatico(activo, hora):
|
||
"""Da de alta (o de baja) en el Programador de tareas de Windows la pasada diaria. La tarea es del
|
||
usuario: corre con su sesión abierta, que es cuando están montados los discos del NAS."""
|
||
hora = (hora or "").strip()
|
||
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", hora):
|
||
return False, "la hora va como 02:00"
|
||
ensayo = os.environ.get("ORGANILAB_ENSAYO", "")
|
||
if ensayo: # las pruebas no tocan el Programador de tareas
|
||
guardar_json(os.path.join(ensayo, "tarea.json"), {"activo": bool(activo), "hora": hora, "orden": _orden_de_la_tarea()})
|
||
return True, ""
|
||
if activo:
|
||
cmd = ["schtasks", "/Create", "/F", "/TN", TAREA_WINDOWS, "/SC", "DAILY", "/ST", hora, "/TR", _orden_de_la_tarea()]
|
||
else:
|
||
cmd = ["schtasks", "/Delete", "/F", "/TN", TAREA_WINDOWS]
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SIN_VENTANA)
|
||
except (OSError, subprocess.SubprocessError) as ex:
|
||
return False, str(ex)
|
||
if r.returncode != 0 and activo:
|
||
return False, (r.stderr or r.stdout or "Windows no aceptó la tarea").strip()
|
||
return True, ""
|
||
|
||
|
||
def configurar_automatico(activo, hora):
|
||
ok, error = programar_automatico(activo, hora or (CONFIG.get("automatico") or {}).get("hora") or "02:00")
|
||
if not ok:
|
||
return {"error": "no se pudo programar: " + error}
|
||
CONFIG.setdefault("automatico", {}).update(activo=bool(activo), hora=(hora or "02:00").strip())
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
return {"ok": True, **estado_automatico()}
|
||
|
||
|
||
def automatico_por_comando():
|
||
"""Lo que lanza la tarea programada. Si el programa ya está abierto con estos datos, se lo pide a él
|
||
(dos procesos moviendo lo mismo se pisarían); si no, lo hace este mismo, sin ventana ni consola."""
|
||
if _soy_la_unica():
|
||
r = automatico(origen="programado")
|
||
return 1 if r.get("error") else 0
|
||
puerto = cargar_json(SESION_PATH, {}).get("puerto")
|
||
if not puerto:
|
||
return 1
|
||
import urllib.request
|
||
try:
|
||
rq = urllib.request.Request(f"http://127.0.0.1:{puerto}/api/automatico/ahora", b'{"origen": "programado"}',
|
||
{"Content-Type": "application/json"})
|
||
urllib.request.urlopen(rq, timeout=30).read()
|
||
return 0
|
||
except Exception: # noqa
|
||
return 1
|
||
|
||
|
||
_LOTES_OTROS = ("stock-", "dup-", "vacias-", "entrada-", "optimizar", "tanda-", "pieza-", "descartados-", "colores-", "respaldo")
|
||
|
||
|
||
def _ultimo_orden(raiz):
|
||
"""El último lote de orden (mover y reorganizar) aplicado sobre esta carpeta y no deshecho: cuándo y cuánto."""
|
||
ent = leer_bitacora(3000)
|
||
deshechos = {e.get("de_lote") for e in ent if e.get("op") == "deshacer"}
|
||
for e in reversed(ent):
|
||
l = e.get("lote") or ""
|
||
if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower() and l and not l.startswith(_LOTES_OTROS) and l not in deshechos:
|
||
return {"lote": l, "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])}
|
||
return None
|
||
|
||
|
||
def _nombre_tipo(t):
|
||
return (R.TIPOS_CLIENTE.get(t) or t or "").split(" · ")[0]
|
||
|
||
|
||
# el orden de la lista de Inicio: lo hecho se queda en su sitio, no se va al final ni desaparece
|
||
_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "fechas", "resto", "banco", "vacias", "stock",
|
||
"repetidos", "convertir", "juntar", "respaldar"]
|
||
|
||
|
||
def _historial_carpeta(raiz):
|
||
"""Lo que ya se hizo en esta carpeta, por acción de Inicio: la última vez y qué fue. Con esto cada paso
|
||
hecho sale con su ✓ en vez de desaparecer de la lista (antes el stock, los repetidos, la bandeja, el
|
||
tipo de trabajo o la conversión, al terminarlos, simplemente se iban, y no se sabía por dónde se iba)."""
|
||
ent = leer_bitacora(20000)
|
||
rl = raiz.lower()
|
||
deshechos = {e.get("de_lote") for e in ent if e.get("op") == "deshacer"}
|
||
h = {}
|
||
|
||
def pon(clave, ts, texto, n=1):
|
||
x = h.setdefault(clave, {"ts": 0, "texto": "", "veces": 0})
|
||
x["veces"] += n
|
||
if (ts or 0) >= x["ts"]:
|
||
x["ts"], x["texto"] = ts or 0, texto
|
||
for e in ent:
|
||
op, l, ts = e.get("op"), e.get("lote") or "", e.get("ts")
|
||
if (e.get("raiz") or "").lower() != rl and rl not in [x.lower() for x in (e.get("raices") or [])]:
|
||
continue
|
||
if l and l in deshechos:
|
||
continue
|
||
if op == "tipo":
|
||
pon("tipo", ts, f"elegiste «{_nombre_tipo(e.get('tipo'))}»")
|
||
elif op == "reloj":
|
||
pon("relojes", ts, f"{_plural(e.get('n') or 0, 'clip corrido', 'clips corridos')} {e.get('dias') or 0:+d} días")
|
||
elif op == "banco_modo":
|
||
pon("banco", ts, f"elegiste ordenarlo por {e.get('modo')}")
|
||
elif op == "nombrar":
|
||
pon("nombrar", ts, f"dijiste qué es «{e.get('ejemplo') or e.get('token')}»")
|
||
elif op == "respaldo":
|
||
pon("respaldar", ts, f"{_plural(e.get('copiados') or 0, 'archivo copiado', 'archivos copiados')} a {e.get('a') or 'el respaldo'}")
|
||
elif op == "tanda":
|
||
pon("convertir", ts, _plural(e.get("clips") or 0, "clip convertido", "clips convertidos"))
|
||
elif op == "fin":
|
||
m = e.get("movimientos") or 0
|
||
if l.startswith("stock-"):
|
||
pon("stock", ts, _plural(m, "archivo apartado", "archivos apartados") + " a «_Para borrar\\stock»")
|
||
elif l.startswith("dup-"):
|
||
pon("repetidos", ts, _plural(m, "copia juntada", "copias juntadas") + " en «Duplicados»")
|
||
elif l.startswith("vacias-"):
|
||
pon("vacias", ts, _plural(m, "carpeta vacía borrada", "carpetas vacías borradas"))
|
||
elif l.startswith("entrada-"):
|
||
pon("entrada", ts, _plural(m, "archivo traído", "archivos traídos"))
|
||
elif l.startswith(("optimizar", "descartados-")):
|
||
pon("convertir", ts, _plural(m, "original reemplazado", "originales reemplazados"))
|
||
elif not l.startswith(("pieza-", "colores-", "tanda-", "respaldo")):
|
||
# aplicar el plan: los pasos que entraron (los lotes de antes de la 3.7.1 no lo apuntaban)
|
||
for p, n in (e.get("pasos") or {"limpiar": 0, "fechas": 0, "resto": 0}).items():
|
||
pon(p, ts, _plural(n, "cambio aplicado", "cambios aplicados") if n else _plural(m, "movimiento", "movimientos"))
|
||
return h
|
||
|
||
|
||
def _traidos_de(raiz):
|
||
"""origen (minúsculas) -> cuándo se trajo, de los «Traer material» hechos a esta carpeta."""
|
||
out = {}
|
||
for e in leer_bitacora(20000):
|
||
if e.get("op") == "fin" and (e.get("lote") or "").startswith("entrada-") and (e.get("raiz") or "").lower() == raiz.lower() and e.get("origen"):
|
||
out[os.path.normpath(e["origen"]).lower()] = e.get("ts")
|
||
return out
|
||
|
||
|
||
def _cuando(ts):
|
||
"""«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40»."""
|
||
if not ts:
|
||
return ""
|
||
t = time.localtime(ts)
|
||
hoy = time.localtime()
|
||
hora = time.strftime("%H:%M", t)
|
||
if t[:3] == hoy[:3]:
|
||
return f"hoy a las {hora}"
|
||
if t[:3] == time.localtime(time.time() - 86400)[:3]:
|
||
return f"ayer a las {hora}"
|
||
return f"el {t.tm_mday} de {R.MESES[t.tm_mon - 1]}" + (f" de {t.tm_year}" if t.tm_year != hoy.tm_year else "") + f" a las {hora}"
|
||
|
||
|
||
def acciones(raiz):
|
||
"""Todo lo que se puede hacer AHORA MISMO en esta carpeta, en orden, cada cosa con su cifra.
|
||
|
||
Es la vista principal: en lugar de repartir el trabajo entre pestañas dentro de pestañas, una sola
|
||
lista dice qué falta y cuánto pesa. Todo sale de consultas baratas sobre el inventario; lo único
|
||
que puede costar es el plan de orden, que solo se usa si ya estaba calculado."""
|
||
raiz = _clave(raiz)
|
||
r = raiz_cfg(raiz)
|
||
if not r:
|
||
return {"error": "carpeta no registrada"}
|
||
con = db()
|
||
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
|
||
out = []
|
||
|
||
def añadir(**kw):
|
||
kw.setdefault("estado", "toca") # toca | hecho | espera
|
||
out.append(kw)
|
||
|
||
if not esc:
|
||
con.close()
|
||
añadir(clave="escanear", titulo="Escanear la carpeta", estado="toca", boton="Escanear",
|
||
que="Lee nombres, tamaños y fechas de todos los archivos. No mueve nada.",
|
||
cifra="", detalle="En una carpeta grande del NAS tarda unos minutos.")
|
||
return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": None}
|
||
|
||
en_papelera = " AND " + " AND ".join(
|
||
["ruta NOT LIKE ? ESCAPE '!'"] * len(R.PAPELERAS))
|
||
args_pap = [esc_like(os.path.join(raiz, pp)) + "%" for pp in R.PAPELERAS]
|
||
|
||
# --- 0. el inventario es de una versión que reconocía peor las cámaras y las fechas ---
|
||
if (esc["reglas"] or 0) < R.VERSION_REGLAS:
|
||
añadir(clave="escanear", titulo="Escanear otra vez", boton="Escanear",
|
||
que="Esta versión reconoce mejor las cámaras, los clips ya convertidos y el año de cada rodaje.",
|
||
cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.",
|
||
ir="escanear")
|
||
|
||
# --- el tipo de trabajo de la carpeta no es el del cliente ---
|
||
# Vía Sumapaz estaba guardado como «proyecto» y su carpeta como «contenido»: el plan, el mapa y «Organizar
|
||
# carpetas» usan el de la carpeta, y nada decía que no cuadraban.
|
||
tc = ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo")
|
||
tr = r.get("tipo", "contenido")
|
||
if tc and tc != tr and tc in PLANTILLAS:
|
||
nom = lambda t: (R.TIPOS_CLIENTE.get(t) or t).split(" · ")[0] # noqa
|
||
añadir(clave="tipo", titulo="Elegir cómo se ordena esta carpeta", boton="Elegir",
|
||
que=f"{r['cliente']} está guardado como «{nom(tc)}», pero esta carpeta se ordena como «{nom(tr)}».",
|
||
cifra="", detalle="El plan, el mapa y «Organizar carpetas» usan el de la carpeta. Elige uno y quedan los dos iguales.",
|
||
ir="tipo", tipos=[tr, tc])
|
||
|
||
# --- la bandeja de entrada: material nuevo esperando a que lo repartan ---
|
||
ent = entrada(raiz)
|
||
if ent["n"]:
|
||
añadir(clave="entrada", titulo="Repartir lo que hay en la bandeja de entrada", boton="Repartir",
|
||
que=f"Material nuevo en «{R.ENTRADA}»: va a su mes, a su día de rodaje y a su cámara.",
|
||
cifra=f"{_plural(ent['n'], 'archivo', 'archivos')} · {_gb(ent['bytes'])}",
|
||
detalle="La app vuelve a leer la carpeta y mueve lo que da por seguro; lo dudoso se queda en la bandeja.",
|
||
ir="entrada")
|
||
|
||
# --- 1. basura y proxies que siguen fuera de la papelera ---
|
||
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? "
|
||
"AND (es_basura = 1 OR es_proxy = 1)" + en_papelera, [raiz] + args_pap).fetchone()
|
||
vieja = con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.join(raiz, R.PAPELERAS[1]),)).fetchone()
|
||
plan_l = PLANES.get(raiz)
|
||
marc_l = [f for f in plan_l["filas"] if f.get("paso") == "limpiar" and f.get("marcado")] if plan_l else None
|
||
sin_l = [f for f in plan_l["filas"] if f.get("paso") == "limpiar" and not f.get("marcado")] if plan_l else []
|
||
if fila["n"] and marc_l is not None and not marc_l:
|
||
# lo que queda no lo aparta la app sola (está en «No tocar», o es un proxy sin su original): está hecho
|
||
# lo seguro; queda mirar
|
||
añadir(clave="limpiar", titulo="Basura y proxies", boton="Ver", estado="espera",
|
||
que="Lo seguro ya está apartado. Lo que queda no se aparta sin que lo marques tú.",
|
||
cifra=f"{_plural(len(sin_l), 'cambio sin marcar', 'cambios sin marcar')} · {_gb(sum(f['bytes'] for f in sin_l))}",
|
||
detalle=("Hay proxies sin su original: pueden ser la única copia." if any("OJO" in f["motivo"] for f in sin_l) else "")
|
||
+ _no_tocar_dentro(con, raiz), ir="plan", paso="limpiar")
|
||
elif 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." + _no_tocar_dentro(con, raiz),
|
||
ir="plan", paso="limpiar")
|
||
elif vieja:
|
||
añadir(clave="limpiar", titulo="Juntar las dos papeleras", boton="Ver y juntar",
|
||
que=f"Lo apartado está repartido entre «{R.PAPELERAS[1]}», el nombre de antes, y «{R.PAPELERA}».",
|
||
cifra=f"{_plural(vieja['n'] or 0, 'archivo', 'archivos')} · {_gb(vieja['bytes'] or 0)}",
|
||
detalle=f"Todo pasa a «{R.PAPELERA}». No se borra nada.",
|
||
ir="plan", paso="limpiar")
|
||
else:
|
||
# lo ya apartado se dice, para que se vea que el cambio se hizo (antes la ficha quedaba muda)
|
||
apartado = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? AND ("
|
||
+ " OR ".join(["ruta LIKE ? ESCAPE '!'"] * len(R.PAPELERAS)) + ")", [raiz] + args_pap).fetchone()
|
||
añadir(clave="limpiar", titulo="Basura y proxies", estado="hecho",
|
||
que="No queda nada suelto que apartar.", cifra="",
|
||
detalle=f"{_plural(apartado['n'], 'archivo', 'archivos')} ya en «{R.PAPELERA}» ({_gb(apartado['b'])}): se borran cuando tú quieras." if apartado["n"] else "")
|
||
|
||
# --- 2. carpetas que la app no reconoce ---
|
||
voc = vocabulario(raiz)
|
||
sin = [t for t in voc if not t.get("cubeta") and not t.get("dentro_pieza")]
|
||
seguras = [t for t in sin if t.get("sug_conf") == "alta"]
|
||
if sin:
|
||
# sin sumar bytes: una carpeta sin reconocer dentro de otra sin reconocer contaría dos veces
|
||
# el mismo material, y salían totales mayores que el propio disco
|
||
con_sug = [t for t in sin if t.get("sugerencia")]
|
||
añadir(clave="nombrar", titulo=("Decir qué son " + _plural(len(sin), "carpeta", "carpetas")) if len(sin) > 1 else "Decir qué es una carpeta", boton="Nombrarlas" if len(sin) > 1 else "Nombrarla",
|
||
que="Sin esto la app no las mueve: se quedan donde están.",
|
||
cifra=_plural(len(sin), "carpeta", "carpetas") + (f" · {len(seguras)} de un clic" if seguras else ""),
|
||
detalle=(f"La app propone qué son {len(con_sug)} de ellas mirando lo que tienen dentro"
|
||
+ (f", y {len(seguras)} con bastante seguridad como para aceptarlas todas juntas." if seguras else ".")
|
||
if con_sug else "Puedes dejarlas sin nombrar: simplemente no se moverán."),
|
||
ir="nombrar")
|
||
else:
|
||
añadir(clave="nombrar", titulo="Carpetas reconocidas", estado="hecho",
|
||
que="La app sabe qué es cada carpeta.", cifra="", detalle="")
|
||
|
||
# --- 3 y 4. ordenar (usa el plan solo si ya estaba calculado) ---
|
||
plan = PLANES.get(raiz)
|
||
ultimo = _ultimo_orden(raiz)
|
||
hecho_det = f"Hecho {_cuando(ultimo['ts'])}: {_plural(ultimo['movimientos'], 'movimiento', 'movimientos')}." if ultimo else ""
|
||
for clave, titulo, que in (("fechas", "Ordenar por año y mes",
|
||
"Meses con nombre completo, grabaciones repartidas por mes y cámara, piezas numeradas."),
|
||
("resto", "Ordenar lo demás",
|
||
"Exportados, proyectos, música y marca a su carpeta.")):
|
||
if plan:
|
||
fs = [f for f in plan["filas"] if f.get("paso") == clave]
|
||
marc = [f for f in fs if f.get("marcado")]
|
||
if fs and not marc:
|
||
# solo queda lo que la app no mueve sola: no es una tarea pendiente, es algo que mirar.
|
||
# Si contara como pendiente, la carpeta no llegaría nunca a estar «al día».
|
||
añadir(clave=clave, titulo=titulo, boton="Ver", que=que, estado="espera",
|
||
cifra=_plural(len(fs), "dudoso", "dudosos"),
|
||
detalle=(hecho_det + " " if hecho_det else "Lo seguro ya está hecho. ")
|
||
+ "Queda lo que la app no se atreve a mover sola: míralo y marca lo que quieras.", ir="plan", paso=clave)
|
||
elif fs:
|
||
añadir(clave=clave, titulo=titulo, boton="Ver y aplicar", que=que,
|
||
cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}",
|
||
detalle=(f"{len(marc)} están marcados; el resto son dudosos y no se mueven salvo que los marques."
|
||
if len(marc) < len(fs) else "Todo se mueve dentro de la misma carpeta."),
|
||
ir="plan", paso=clave)
|
||
else:
|
||
añadir(clave=clave, titulo=titulo, estado="hecho", que="Nada que mover.", cifra="", detalle=hecho_det)
|
||
elif REPLAN.get(raiz):
|
||
# se acaba de mover algo y el plan se está volviendo a pensar: en un momento se sabe qué queda
|
||
añadir(clave=clave, titulo=titulo, que=que, cifra="", estado="espera", calculando=True,
|
||
detalle=(hecho_det + " " if hecho_det else "") + "Comprobando qué queda…", ir="plan", paso=clave)
|
||
else:
|
||
# sin el plan calculado no se sabe si queda algo: se ofrece calcularlo, pero no cuenta como
|
||
# pendiente (si no, después de ordenarlo todo la lista seguía diciendo que faltaban dos cosas)
|
||
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera",
|
||
detalle=(hecho_det + " " if hecho_det else "") + "Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave)
|
||
|
||
# --- clips con fecha imposible: una cámara con el reloj sin poner ---
|
||
rr = con.execute(f"SELECT COUNT(*) n, COUNT(DISTINCT dir || '|' || fuente) g, COALESCE(SUM(bytes), 0) b FROM archivos "
|
||
f"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) "
|
||
f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?",
|
||
(raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchone()
|
||
if rr["n"]:
|
||
añadir(clave="relojes", titulo=f"Corregir la fecha de {_plural(rr['n'], 'clip', 'clips')} de cámara", boton="Corregir",
|
||
que="Su cámara tenía el reloj sin poner (2008, 2009…): se ordenarían en un año que no es.",
|
||
cifra=f"{_plural(rr['g'], 'tanda', 'tandas')} · {_gb(rr['b'])}", estado="espera",
|
||
detalle="Pones la fecha real del primer clip de cada tanda y la app corre los demás lo mismo. Se deshace.",
|
||
ir="relojes")
|
||
|
||
# --- el banco de material: los temas se quedan; dentro, por fecha y cámara ---
|
||
bancos = _bancos_de(con, raiz)
|
||
if bancos:
|
||
b0 = bancos[0]
|
||
nombre_b = b0["nombre"]
|
||
prot = any(b["protegido"] for b in bancos)
|
||
que_b = "Los temas se quedan como están; dentro de cada uno, las grabaciones por fecha de rodaje y cámara."
|
||
nota_b = ("Su nombre dice «No tocar»: la app lo propone todo sin marcar y no entra en «hacer de una vez». "
|
||
"Revísalo en «Qué va a pasar» y marca lo que quieras." if prot else "")
|
||
if plan:
|
||
fs = [f for f in plan["filas"] if f.get("paso") == "banco"]
|
||
marc = [f for f in fs if f.get("marcado")]
|
||
if fs:
|
||
añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), boton="Ver", que=que_b,
|
||
estado="toca" if marc else "espera",
|
||
cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}",
|
||
detalle=nota_b or f"{len(marc)} marcados.", ir="plan", paso="banco")
|
||
else:
|
||
añadir(clave="banco", titulo=f"Banco de material «{nombre_b}»", estado="hecho", que=que_b, cifra="",
|
||
detalle="Sus grabaciones ya están por fecha y cámara.")
|
||
else:
|
||
añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), que=que_b, estado="espera",
|
||
cifra=_gb(sum(b["bytes"] for b in bancos)), calculando=bool(REPLAN.get(raiz)),
|
||
boton="" if REPLAN.get(raiz) else "Calcular",
|
||
detalle=("Comprobando qué queda… " if REPLAN.get(raiz) else "Hay que calcular el plan para verlo. ") + nota_b,
|
||
ir="plan", paso="banco")
|
||
|
||
# --- 5. carpetas que quedaron vacías ---
|
||
vac = carpetas_vacias(raiz)
|
||
if vac["n"]:
|
||
dentro = sum(c["subcarpetas"] for c in vac["carpetas"])
|
||
añadir(clave="vacias", titulo="Borrar carpetas vacías", boton="Ver y borrar",
|
||
que="Carpetas sin un solo archivo dentro: lo que queda después de mover el material.",
|
||
cifra=_plural(vac["n"], "carpeta", "carpetas") + (f" · {dentro} dentro" if dentro else ""),
|
||
detalle="Es lo único que la app borra, y solo porque no hay nada dentro. Se revisan una a "
|
||
"una y se deshace desde Historial.",
|
||
ir="vacias")
|
||
else:
|
||
añadir(clave="vacias", titulo="Carpetas vacías", estado="hecho",
|
||
que="No queda ninguna carpeta hueca.", cifra="", detalle="")
|
||
|
||
# --- 6. stock de Envato viejo ---
|
||
tope = limite_meses(3)
|
||
viejo = [f for f in con.execute(
|
||
"SELECT bytes, fecha, anio_ruta, mes_ruta FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0"
|
||
+ en_papelera, [raiz] + args_pap).fetchall() if es_viejo(f, tope)]
|
||
if viejo:
|
||
añadir(clave="stock", titulo="Apartar stock de Envato viejo", boton="Ver y apartar",
|
||
que="De proyectos ya publicados: se vuelve a descargar con la suscripción.",
|
||
cifra=f"{_plural(len(viejo), 'archivo', 'archivos')} · {_gb(sum(f['bytes'] for f in viejo))}",
|
||
detalle="Son los de proyectos de más de 3 meses. Puedes ajustar el corte.",
|
||
ir="stock")
|
||
|
||
# --- 6. material repetido ---
|
||
# lo que ya está apartado en una carpeta «Duplicados» no cuenta: si contara, esto no acabaría nunca
|
||
sin_dup = " AND instr(lower(ruta), ?) = 0"
|
||
arg_dup = [os.sep + R.DUPLICADOS.lower() + os.sep]
|
||
rep_fila = con.execute(
|
||
"SELECT COALESCE(SUM(n - 1), 0) n, COALESCE(SUM((n - 1) * bytes), 0) b FROM "
|
||
"(SELECT nombre, bytes, COUNT(*) n FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 "
|
||
"AND bytes > 5000000" + en_papelera + sin_dup + " GROUP BY nombre, bytes HAVING COUNT(*) > 1)",
|
||
[raiz] + args_pap + arg_dup).fetchone()
|
||
# el mismo clip con otra calidad: se llama igual y pesa distinto. Cuenta lo que aún no se ha leído y
|
||
# lo que, ya leído, dura lo mismo; dos clips que se llaman igual y duran distinto son clips distintos.
|
||
por_nombre = defaultdict(list)
|
||
for f in con.execute(
|
||
"SELECT lower(nombre) nm, bytes, dur, analizado FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 "
|
||
"AND bytes > 5000000 AND rol IN ('camara_raw','camara_video','celular_video') AND ext IN (%s)"
|
||
% ",".join("?" * len(_EXT_VIDEO_DUP)) + en_papelera + sin_dup,
|
||
[raiz] + sorted(_EXT_VIDEO_DUP) + args_pap + arg_dup).fetchall():
|
||
if f["analizado"] != 2: # 2 = no se pudo leer: no hay con qué comparar
|
||
por_nombre[f["nm"]].append(f)
|
||
por_comparar = 0
|
||
for fs in por_nombre.values():
|
||
if len(fs) < 2 or len({f["bytes"] for f in fs}) < 2:
|
||
continue
|
||
if any(f["analizado"] != 1 or not f["dur"] for f in fs):
|
||
por_comparar += len(fs) - 1
|
||
else:
|
||
durs = sorted(f["dur"] for f in fs)
|
||
por_comparar += sum(1 for a, b in zip(durs, durs[1:]) if b - a <= max(0.5, 0.01 * a))
|
||
if rep_fila["n"] or por_comparar:
|
||
partes = ([f"{_plural(rep_fila['n'], 'copia de más', 'copias de más')} · {_gb(rep_fila['b'])}"] if rep_fila["n"] else []) \
|
||
+ ([f"{_plural(por_comparar, 'clip', 'clips')} con otra calidad por comparar"] if por_comparar else [])
|
||
añadir(clave="repetidos", titulo="Juntar material repetido", boton="Ver repetidos",
|
||
que="Mismo nombre y mismo peso en varias carpetas, o el mismo clip guardado con dos calidades.",
|
||
cifra=" · ".join(partes),
|
||
detalle="Se comprueba el contenido antes de tocar nada; la copia buena se queda donde está y la otra va a «Duplicados» a su lado.",
|
||
ir="repetidos")
|
||
|
||
# --- 7. convertir clips viejos ---
|
||
o = CONFIG["optimizar"]
|
||
# los clips que ya salieron del convertidor siguen siendo de cámara, pero no están por aligerar
|
||
por_aligerar = [f for f in con.execute(
|
||
"SELECT nombre, bytes, analizado FROM archivos WHERE raiz = ? "
|
||
"AND rol IN ('camara_raw','camara_video') AND es_proxy = 0 AND es_basura = 0 AND bytes >= ?" + en_papelera,
|
||
[raiz, int(o.get("min_mb", 300)) * 1024 * 1024] + args_pap).fetchall() if not R.es_conversion(f["nombre"])]
|
||
cam = {"n": len(por_aligerar), "b": sum(f["bytes"] or 0 for f in por_aligerar),
|
||
"ana": sum(1 for f in por_aligerar if f["analizado"] == 1)}
|
||
hechos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado = 'reemplazado'",
|
||
(raiz.lower(),)).fetchone()["n"]
|
||
if cam["n"]:
|
||
if not (cam["ana"] or 0):
|
||
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips",
|
||
que="Pasan a un formato unas 10 veces más ligero. El original no se toca hasta que reemplaces.",
|
||
cifra=f"{_plural(cam['n'], 'clip', 'clips')} · {_gb(cam['b'])}",
|
||
detalle="Primero hay que leerlos para saber cuáles conviene convertir. Va en segundo plano.",
|
||
ir="convertir")
|
||
else:
|
||
# ya leídos: cuentan solo los que de verdad adelgazan (ni conversiones, ni lo que ya viene comprimido)
|
||
cands = [c for c in candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit"))
|
||
if not c["excluido"] and not c["en_cola"] and c["rol"] in ("camara_raw", "camara_video")]
|
||
sin_leer = cam["n"] - cam["ana"]
|
||
if cands:
|
||
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips",
|
||
que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.",
|
||
cifra=f"{_plural(len(cands), 'clip', 'clips')} · {_gb(sum(c['bytes'] for c in cands))} → unos {_gb(sum(c['estimado'] for c in cands))}",
|
||
detalle=(f"{sum(1 for c in cands if c['motor'] != 'ffmpeg')} son BRAW. " if any(c["motor"] != "ffmpeg" for c in cands) else "")
|
||
+ (f"{hechos} ya reemplazados." if hechos else "El resto ya viene comprimido o es reciente."),
|
||
ir="convertir")
|
||
elif sin_leer > 0:
|
||
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips",
|
||
que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.",
|
||
cifra=f"{_plural(sin_leer, 'clip', 'clips')} por leer",
|
||
detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.",
|
||
ir="convertir")
|
||
|
||
# --- el mismo cliente en otros discos ---
|
||
sitios = otros_sitios(raiz)
|
||
if sitios:
|
||
traidos = _traidos_de(raiz)
|
||
for s in sitios:
|
||
s["traido"] = traidos.get(os.path.normpath(s["ruta"]).lower())
|
||
faltan = [s for s in sitios if not s["traido"]]
|
||
añadir(clave="juntar", titulo=(f"{r['cliente']} también está en {_plural(len(faltan), 'sitio', 'sitios')} más" if faltan
|
||
else f"Lo de {r['cliente']} en otros discos"),
|
||
boton="Ver" if faltan else "", estado="espera" if faltan else "hecho",
|
||
que=("Se puede traer aquí con copia comprobada: el original no se toca, y lo que llega se reparte por mes y cámara."
|
||
if faltan else "Ya está traído todo lo que había en otros discos."),
|
||
cifra=" · ".join(s["ruta"] for s in faltan[:3]) + (f" y {len(faltan) - 3} más" if len(faltan) > 3 else ""),
|
||
detalle="Míralo antes: una carpeta de entregas o de exportados puede ser una copia que conviene dejar donde está." if faltan else "",
|
||
ir="juntar", sitios=sitios)
|
||
|
||
# --- 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()
|
||
hist = _historial_carpeta(raiz)
|
||
hay = {a["clave"] for a in out}
|
||
for clave, titulo, que in (("tipo", "Cómo se ordena esta carpeta", f"Como «{_nombre_tipo(r.get('tipo', 'contenido'))}», igual que su cliente."),
|
||
("entrada", "Bandeja de entrada", "Vacía: lo que entró ya está repartido."),
|
||
("stock", "Stock de Envato viejo", "No queda stock viejo por apartar."),
|
||
("repetidos", "Material repetido", "No quedan copias de más por juntar."),
|
||
("convertir", "Clips de cámara", "No quedan clips que compense convertir."),
|
||
("relojes", "Fechas de cámara", "No queda ningún clip con fecha imposible.")):
|
||
if clave in hist and clave not in hay:
|
||
añadir(clave=clave, titulo=titulo, estado="hecho", que=que, cifra="", detalle="")
|
||
for a in out:
|
||
x = hist.get(a["clave"])
|
||
if not x or "Hecho " in (a.get("detalle") or ""):
|
||
continue
|
||
cuando = _cuando(x["ts"])
|
||
if a["clave"] == "nombrar" and x["veces"] > 1:
|
||
a["hecho"] = f"Ya nombraste {x['veces']}; la última {cuando}."
|
||
elif a["estado"] == "hecho":
|
||
a["hecho"] = f"Hecho {cuando}: {x['texto']}."
|
||
else:
|
||
a["hecho"] = f"Última vez {cuando}: {x['texto']}."
|
||
out.sort(key=lambda a: _ORDEN_ACCIONES.index(a["clave"]) if a["clave"] in _ORDEN_ACCIONES else 99)
|
||
# «lo seguro» son las acciones que se pueden hacer sin elegir nada: no entran convertir ni respaldar
|
||
auto = [a["clave"] for a in out if a["estado"] == "toca" and a["clave"] in ("limpiar", "fechas", "resto", "stock", "repetidos")]
|
||
if (esc["reglas"] or 0) < R.VERSION_REGLAS:
|
||
auto = [] # con un inventario viejo no se hace nada «de una vez»: primero se relee
|
||
return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc), "ultimo": ultimo,
|
||
"calculando": REPLAN.get(raiz) is not None,
|
||
"automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None}
|
||
|
||
|
||
def _no_tocar_dentro(con, raiz):
|
||
"""« De ellos, 366 GB están dentro de «Material Vía (No tocar)»: van sin marcar.» para la ficha de basura y proxies."""
|
||
partes = []
|
||
for b in _bancos_de(con, raiz):
|
||
if not b["protegido"]:
|
||
continue
|
||
x = con.execute("SELECT COALESCE(SUM(bytes), 0) b FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1) "
|
||
"AND ruta LIKE ? ESCAPE '!'", (raiz, esc_like(con_sep(b["ruta"])) + "%")).fetchone()["b"]
|
||
if x:
|
||
partes.append(f"{_gb(x)} están dentro de «{b['nombre']}», que dice «No tocar»")
|
||
return (" De ellos, " + "; y ".join(partes) + ": van sin marcar, para que decidas tú.") if partes else ""
|
||
|
||
|
||
def _bancos_de(con, raiz):
|
||
"""Los bancos de material de una carpeta de cliente: los de su primer nivel. Una copia dentro de otra
|
||
carpeta (en Vía Sumapaz, «_Duplicados_para_borrar\\Material Vía (No tocar)») no es el banco."""
|
||
base = raiz.rstrip(os.sep).count(os.sep) + 1
|
||
out = []
|
||
for c in con.execute("SELECT ruta, nombre, n, bytes FROM carpetas WHERE raiz = ? ORDER BY length(ruta)", (raiz,)).fetchall():
|
||
if c["ruta"].count(os.sep) != base:
|
||
continue
|
||
if R.cubeta_carpeta(c["nombre"], DICC["carpetas"])[0] == "BANCO":
|
||
out.append({"ruta": c["ruta"], "nombre": c["nombre"], "n": c["n"] or 0, "bytes": c["bytes"] or 0,
|
||
"protegido": R.es_no_tocar(c["nombre"])})
|
||
return out
|
||
|
||
|
||
def carpeta_detalle(raiz, ruta=None):
|
||
"""Lo que hay dentro de cualquier carpeta del cliente, a cualquier profundidad: sus subcarpetas con su peso
|
||
(para entrar en «Qué pesa» nivel a nivel), de qué es lo que guarda, de qué cámaras y de qué fechas, y qué
|
||
hace la app con ella. Antes «Qué pesa» solo enseñaba el primer nivel, y una carpeta de 3,6 TB era un bloque
|
||
gris sin nada dentro: parecía que el escaneo no había entrado."""
|
||
raiz = _clave(raiz)
|
||
ruta = os.path.normpath(ruta or raiz)
|
||
if ruta.lower() != raiz.lower() and not dentro_de(raiz, ruta):
|
||
return {"error": "esa carpeta no es de este cliente"}
|
||
con = db()
|
||
q = lambda sql, *a: [dict(x) for x in con.execute(sql, a).fetchall()] # noqa
|
||
pref = esc_like(con_sep(ruta)) + "%"
|
||
hijos = q("SELECT ruta, nombre, n, bytes, es_proxy, es_basura FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' "
|
||
"AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC", raiz, pref, pref + esc_like(os.sep) + "%")
|
||
rel0 = os.path.relpath(ruta, raiz)
|
||
en_banco = rel0 != "." and any(R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO" for s in rel0.split(os.sep))
|
||
for h in hijos:
|
||
cub = "PROXY" if h["es_proxy"] else "BASURA" if h["es_basura"] else \
|
||
"BANCO" if en_banco else (R.cubeta_carpeta(h["nombre"], DICC["carpetas"])[0] or "")
|
||
h.update(cubeta=cub, color=R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]))
|
||
donde = "(dir = ? OR dir LIKE ? ESCAPE '!')"
|
||
total = q(f"SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0]
|
||
sueltos = q("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, ruta)[0]
|
||
roles = q(f"SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} GROUP BY rol ORDER BY bytes DESC", raiz, ruta, pref)
|
||
nombres_f = {}
|
||
for k, _, d in R.FIRMAS:
|
||
nombres_f.setdefault(k, d)
|
||
fuentes = q(f"SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} AND fuente != '' "
|
||
f"AND fuente != 'STOCK' GROUP BY fuente ORDER BY bytes DESC", raiz, ruta, pref)
|
||
fechas = q(f"SELECT MIN(substr(fecha,1,7)) desde, MAX(substr(fecha,1,7)) hasta, COUNT(DISTINCT substr(fecha,1,7)) meses "
|
||
f"FROM archivos WHERE raiz = ? AND {donde} AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado')",
|
||
raiz, ruta, pref)[0]
|
||
apartable = q(f"SELECT COALESCE(SUM(CASE WHEN es_proxy = 1 THEN bytes END), 0) proxies, "
|
||
f"COALESCE(SUM(CASE WHEN es_basura = 1 AND es_proxy = 0 THEN bytes END), 0) basura FROM archivos "
|
||
f"WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0]
|
||
sub = [x["ruta"] for x in q("SELECT ruta FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!'", raiz, pref)]
|
||
niveles = max((s.count(os.sep) - ruta.rstrip(os.sep).count(os.sep) for s in sub), default=0)
|
||
grandes = q("SELECT nombre, bytes FROM archivos WHERE raiz = ? AND dir = ? ORDER BY bytes DESC LIMIT 6", raiz, ruta)
|
||
con.close()
|
||
# qué hace la app con ella: si está dentro de un banco de material, eso manda
|
||
trato = {"cubeta": "", "texto": ""}
|
||
rel = os.path.relpath(ruta, raiz)
|
||
if rel != ".":
|
||
segs = rel.split(os.sep)
|
||
for s in segs:
|
||
if R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO":
|
||
trato = {"cubeta": "BANCO", "texto": f"Está en el banco de material «{s}»: los temas se quedan como están y, "
|
||
f"dentro, las grabaciones van por fecha de rodaje y cámara."
|
||
+ (" Su nombre dice «No tocar»: todo se propone sin marcar." if R.es_no_tocar(s) else "")}
|
||
break
|
||
else:
|
||
cub, mot = R.cubeta_carpeta(segs[-1], DICC["carpetas"])
|
||
trato = {"cubeta": cub or "", "texto": (f"La app la reconoce como «{R.CUBETAS.get(cub, cub)}» ({mot})." if cub else
|
||
"La app no reconoce el nombre: el plan decide por lo que tiene dentro.")}
|
||
plan = PLANES.get(raiz)
|
||
en_plan = None
|
||
if plan:
|
||
rc = os.path.normcase(con_sep(ruta)) if rel != "." else ""
|
||
fs = [f for f in plan["filas"] if not rc or os.path.normcase(f["origen"] + os.sep).startswith(rc)
|
||
or any(os.path.normcase(a).startswith(rc) for a in (f["archivos"] or [])[:1])]
|
||
en_plan = {"filas": len(fs), "marcadas": sum(1 for f in fs if f["marcado"]), "bytes": sum(f["bytes"] for f in fs)}
|
||
return {"raiz": raiz, "ruta": ruta, "rel": "" if rel == "." else rel, "nombre": os.path.basename(ruta) or ruta,
|
||
"n": total["n"], "bytes": total["bytes"], "sueltos": sueltos, "hijos": hijos, "niveles": niveles,
|
||
"carpetas": len(sub), "roles": [dict(x, que=R.ROL_QUE.get(x["rol"], x["rol"])) for x in roles],
|
||
"fuentes": [dict(x, que=nombres_f.get(x["fuente"], x["fuente"])) for x in fuentes],
|
||
"fechas": fechas, "apartable": apartable, "grandes": grandes, "trato": trato, "en_plan": en_plan}
|
||
|
||
|
||
def _inventario_resumen(raiz):
|
||
"""Cuántas carpetas leyó el escaneo y hasta qué profundidad: para que se vea que entró en todas."""
|
||
con = db()
|
||
rutas = [x[0] for x in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
|
||
con.close()
|
||
base = raiz.rstrip(os.sep).count(os.sep)
|
||
return {"carpetas": len(rutas), "niveles": max((x.count(os.sep) - base for x in rutas), default=0)}
|
||
|
||
|
||
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], "inventario": _inventario_resumen(raiz) if esc else None,
|
||
"tipo_cliente": ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo"),
|
||
"convertir": {"camara_bytes": cam_bytes, "raw_bytes": g.get("camara_raw", {}).get("bytes", 0), "trabajos": trabajos,
|
||
"analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0,
|
||
"cola_activa": COLA["activa"]},
|
||
"respaldo": {"destino": CONFIG.get("respaldo", {}).get("destino", ""), "plan": ({"n": resp_plan["n"], "bytes": resp_plan["bytes"], "base": resp_plan.get("base")} if resp_plan else None),
|
||
"estado": RESPALDO.get("estado"), "raw_pendientes": (resp_plan or {}).get("raw_omitidos", {}).get("n", 0)},
|
||
"asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None,
|
||
}
|
||
|
||
|
||
class ServidorLocal(ThreadingHTTPServer):
|
||
"""El servidor de la interfaz, con el puerto para él solo. ThreadingHTTPServer pide «reutilizar dirección»,
|
||
y en Windows eso significa que dos programas pueden escuchar a la vez en el mismo puerto y las peticiones
|
||
van a cualquiera de los dos: dos OrganiLAB con datos distintos (el instalado y una copia suelta, o el de
|
||
ensayo y el de verdad) se contestaban el uno por el otro sin que nada fallara a la vista."""
|
||
allow_reuse_address = False
|
||
|
||
def server_bind(self):
|
||
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||
super().server_bind()
|
||
|
||
|
||
class Manejador(BaseHTTPRequestHandler):
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
def log_message(self, fmt, *args):
|
||
pass
|
||
|
||
def _json(self, datos, codigo=200):
|
||
cuerpo = json.dumps(datos, ensure_ascii=False, default=str).encode("utf-8")
|
||
self.send_response(codigo)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(cuerpo)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.end_headers()
|
||
self.wfile.write(cuerpo)
|
||
|
||
def _cuerpo(self):
|
||
n = int(self.headers.get("Content-Length") or 0)
|
||
if not n:
|
||
return {}
|
||
try:
|
||
return json.loads(self.rfile.read(n).decode("utf-8"))
|
||
except ValueError:
|
||
return {}
|
||
|
||
def _es_local(self):
|
||
"""Solo acepta peticiones que vienen de la propia página (evita que otra web abierta en el navegador dé órdenes)."""
|
||
host = (self.headers.get("Host") or "").lower()
|
||
if host not in (f"127.0.0.1:{PUERTO}", f"localhost:{PUERTO}"):
|
||
return False
|
||
origen = self.headers.get("Origin")
|
||
if origen and origen.lower() not in (f"http://127.0.0.1:{PUERTO}", f"http://localhost:{PUERTO}"):
|
||
return False
|
||
return True
|
||
|
||
def do_GET(self):
|
||
ULTIMA_PETICION[0] = time.time()
|
||
if self.path.startswith("/api/") and not self._es_local():
|
||
return self._json({"error": "petición no permitida"}, 403)
|
||
u = urlparse(self.path)
|
||
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
||
ruta = u.path
|
||
try:
|
||
if ruta in ("/", "/index.html"):
|
||
with open(os.path.join(BASE, "static", "index.html"), "rb") as f:
|
||
cuerpo = f.read()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(cuerpo)))
|
||
self.end_headers()
|
||
self.wfile.write(cuerpo)
|
||
return
|
||
if ruta.startswith("/static/") and re.fullmatch(r"/static/[A-Za-z0-9_.-]+", ruta):
|
||
p = os.path.join(BASE, "static", ruta[len("/static/"):])
|
||
if not os.path.isfile(p):
|
||
return self._json({"error": "no existe"}, 404)
|
||
tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css",
|
||
".js": "text/javascript", ".woff2": "font/woff2"}
|
||
with open(p, "rb") as f:
|
||
cuerpo = f.read()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", tipos.get(os.path.splitext(p)[1].lower(), "application/octet-stream"))
|
||
self.send_header("Content-Length", str(len(cuerpo)))
|
||
self.send_header("Cache-Control", "max-age=86400")
|
||
self.end_headers()
|
||
self.wfile.write(cuerpo)
|
||
return
|
||
if ruta == "/api/estado":
|
||
return self._json(estado_general())
|
||
if ruta == "/api/aplicando": # la barra de «Aplicar», sondeada mientras corre
|
||
return self._json(dict(APLICANDO))
|
||
if ruta == "/api/soltado":
|
||
r = soltado(q.get("nombre", ""))
|
||
return self._json({"ruta": r, "es_archivo": bool(r) and os.path.isfile(r)})
|
||
if ruta == "/api/carpeta/acciones":
|
||
return self._json(acciones(q["raiz"]))
|
||
if ruta == "/api/carpeta/estado":
|
||
return self._json(estado_carpeta(q["raiz"]))
|
||
if ruta == "/api/mapa":
|
||
return self._json(mapa(q["raiz"]))
|
||
if ruta == "/api/relojes":
|
||
return self._json(relojes(q["raiz"]))
|
||
if ruta == "/api/buscar":
|
||
r = buscar(q.get("q", ""))
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/mapa/carpeta": # «Qué pesa», nivel a nivel
|
||
r = carpeta_detalle(q["raiz"], q.get("ruta"))
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/actualizacion":
|
||
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
|
||
if ruta == "/api/asistente/pasos":
|
||
raiz = os.path.normpath(q["raiz"])
|
||
r = raiz_cfg(raiz)
|
||
if not r:
|
||
return self._json({"error": "raíz no registrada"}, 400)
|
||
d = asistente.diagnostico(raiz)
|
||
filas = Plan(r).correr()["filas"]
|
||
asistente.PLAN_CACHE[raiz] = filas
|
||
return self._json({"diagnostico": d, "pasos": asistente.pasos(d, filas), "movimientos": len(filas)})
|
||
if ruta == "/api/discos":
|
||
return self._json(discos())
|
||
if ruta == "/api/vocabulario":
|
||
return self._json({"tokens": vocabulario(os.path.normpath(q["raiz"])), "diccionario": DICC})
|
||
if ruta == "/api/diccionario":
|
||
return self._json(DICC)
|
||
if ruta == "/api/plan":
|
||
r = raiz_cfg(q["raiz"])
|
||
if not r:
|
||
return self._json({"error": "raíz no configurada"}, 400)
|
||
plan = plan_en_marcha(r["ruta"]) or Plan(r).correr()
|
||
PLANES[plan["raiz"]] = plan
|
||
return self._json(plan)
|
||
if ruta == "/api/bitacora":
|
||
ent = leer_bitacora(3000)
|
||
lotes = {}
|
||
for e in ent:
|
||
l = e.get("lote")
|
||
if not l:
|
||
continue
|
||
lo = lotes.setdefault(l, {"lote": l, "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})
|
||
if e.get("op") == "mover":
|
||
lo["movimientos"] += 1
|
||
if len(lo["ejemplos"]) < 5:
|
||
lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")})
|
||
if e.get("op") == "mkdir": # una pieza nueva: cuentan las carpetas creadas
|
||
lo["movimientos"] += 1
|
||
if e.get("op") == "tanda": # una tanda del convertidor: su resumen
|
||
lo["raiz"] = e.get("raiz", "") or ", ".join(e.get("raices") or [])
|
||
lo["movimientos"] = e.get("clips", 0)
|
||
lo["errores"] = e.get("fallos", 0)
|
||
lo["tanda"] = {k: e.get(k) for k in ("clips", "bytes_in", "bytes_out", "seg", "nota", "reemplazados", "descartados", "cliente")}
|
||
if e.get("op") == "fin":
|
||
lo["raiz"] = e.get("raiz", "")
|
||
lo["errores"] = len(e.get("errores") or [])
|
||
if l.startswith("entrada-"): # lo traído de una tarjeta: copias, no movimientos
|
||
lo["movimientos"] = e.get("movimientos", 0)
|
||
if e.get("origen") and e.get("destino"):
|
||
lo["ejemplos"].append({"de": e["origen"], "a": e["destino"]})
|
||
if e.get("op") == "deshacer":
|
||
lotes.setdefault(e.get("de_lote"), {"lote": e.get("de_lote"), "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})["deshecho"] = True
|
||
return self._json({"lotes": sorted([v for k, v in lotes.items() if not k.endswith("-deshecho")], key=lambda x: -(x["ts"] or 0))})
|
||
if ruta == "/api/optimizar/candidatos":
|
||
o = CONFIG["optimizar"]
|
||
return self._json({"candidatos": candidatos(os.path.normpath(q["raiz"]), q.get("meses", o["meses_minimos"]), q.get("perfil", o["perfil"]),
|
||
q.get("min_mb", o["min_mb"]), q.get("exportados") == "1", q.get("formato", o.get("formato", "h265_10bit")),
|
||
q.get("ahorro"))})
|
||
if ruta == "/api/optimizar/motores":
|
||
return self._json(motores())
|
||
if ruta == "/api/optimizar/cola":
|
||
con = db()
|
||
# con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar». Con la
|
||
# carpeta, solo los suyos y todos: hasta la 3.8 eran los 500 últimos de todas, y con 1.920 en espera
|
||
# en Vía Sumapaz los de Línea Gene no salían (ni su «Reemplazar 12 verificados»)
|
||
k = (q.get("raiz") or "").strip()
|
||
filtro, args = ("WHERE lower(t.raiz) = ? ", [os.path.normpath(k).lower()]) if k else ("", [])
|
||
filas = [dict(r) for r in con.execute("SELECT t.*, a.dur AS dur, a.fps AS fps FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||
+ filtro + "ORDER BY t.id DESC LIMIT " + ("10000" if k else "500"), args).fetchall()]
|
||
for f in filas: # un original ya reemplazado vive en _Para borrar/originales: su ficha está ahí
|
||
if f.get("dur") is None and f["estado"] == "reemplazado":
|
||
otra = _ruta_original(f)
|
||
a = con.execute("SELECT dur, fps FROM archivos WHERE ruta = ?", (otra,)).fetchone() if otra else None
|
||
if a:
|
||
f["dur"], f["fps"] = a["dur"], a["fps"]
|
||
con.close()
|
||
try:
|
||
eta = eta_cola() if COLA["activa"] else None
|
||
except Exception: # noqa
|
||
eta = None
|
||
if COLA.get("ultima") is None:
|
||
COLA["ultima"] = _ultima_tanda_guardada()
|
||
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta,
|
||
"esperando": COLA.get("esperando") or "", "ultima": COLA.get("ultima") or None}})
|
||
if ruta == "/api/optimizar/tanda.csv": # la tanda, en CSV
|
||
csv_ = tanda_csv(q.get("lote") or "")
|
||
if csv_ is None:
|
||
return self._json({"error": "no hay una tanda con ese nombre"}, 404)
|
||
cuerpo = csv_.encode("utf-8-sig")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
||
self.send_header("Content-Disposition", f"attachment; filename=\"OrganiLAB-{q.get('lote') or 'tanda'}.csv\"")
|
||
self.send_header("Content-Length", str(len(cuerpo)))
|
||
self.end_headers()
|
||
self.wfile.write(cuerpo)
|
||
return
|
||
if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar»
|
||
try:
|
||
jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0))
|
||
except (ValueError, TypeError) as ex:
|
||
return self._json({"error": str(ex)}, 400)
|
||
with open(jpg, "rb") as fh:
|
||
cuerpo = fh.read()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "image/jpeg")
|
||
self.send_header("Content-Length", str(len(cuerpo)))
|
||
self.send_header("Cache-Control", "private, max-age=86400")
|
||
self.end_headers()
|
||
self.wfile.write(cuerpo)
|
||
return
|
||
if ruta == "/api/stock":
|
||
tipos = [t for t in (q.get("tipos") or "Video").split(",") if t]
|
||
return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
|
||
if ruta == "/api/papelera":
|
||
return self._json(papelera(os.path.normpath(q["raiz"])))
|
||
if ruta == "/api/vacias":
|
||
return self._json(carpetas_vacias(os.path.normpath(q["raiz"])))
|
||
if ruta == "/api/estructura/archivo":
|
||
return self._json(donde_iria(q.get("nombre", ""), q.get("tipo") or "contenido"))
|
||
if ruta == "/api/estructura/existen":
|
||
try:
|
||
segs = json.loads(q.get("ruta") or "[]")
|
||
except ValueError:
|
||
segs = []
|
||
cs = carpetas_como(os.path.normpath(q["raiz"]), segs) if segs else []
|
||
base = os.path.normpath(q["raiz"])
|
||
return self._json({"n": len(cs), "carpetas": [os.path.relpath(c, base) for c in cs[:20]]})
|
||
if ruta == "/api/estructura":
|
||
raiz = q.get("raiz")
|
||
tipo = q.get("tipo")
|
||
if raiz and not tipo:
|
||
r = raiz_cfg(os.path.normpath(raiz))
|
||
tipo = r.get("tipo") if r else None
|
||
return self._json(mapa_estructura(tipo or "contenido", os.path.normpath(raiz) if raiz else None))
|
||
if ruta == "/api/duplicados":
|
||
return self._json(duplicados(os.path.normpath(q["raiz"]), calidades=q.get("calidades") == "1"))
|
||
if ruta == "/api/respaldo/plan":
|
||
raiz = os.path.normpath(q["raiz"])
|
||
destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "")
|
||
if not destino:
|
||
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
|
||
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0))
|
||
PLANES_RESPALDO[raiz] = plan
|
||
# calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
|
||
# reescribía config.json)
|
||
return self._json({k: v for k, v in plan.items() if k != "items"})
|
||
if ruta == "/api/respaldo/estado":
|
||
return self._json(RESPALDO)
|
||
if ruta == "/api/buscar-carpeta":
|
||
return self._json({"coincidencias": buscar_carpeta(q.get("nombre", ""))})
|
||
if ruta == "/api/entrada":
|
||
return self._json(entrada(q["raiz"]))
|
||
if ruta == "/api/automatico":
|
||
return self._json(estado_automatico())
|
||
if ruta == "/api/pieza/previa":
|
||
return self._json(nueva_pieza(q["raiz"], q.get("titulo", ""), q.get("anio"), q.get("mes"), crear=False))
|
||
return self._json({"error": "no existe"}, 404)
|
||
except Exception as ex: # noqa
|
||
import traceback
|
||
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
|
||
|
||
def do_POST(self):
|
||
ULTIMA_PETICION[0] = time.time()
|
||
u = urlparse(self.path)
|
||
ruta = u.path
|
||
if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"):
|
||
return self._json({"error": "petición no permitida"}, 403)
|
||
d = self._cuerpo()
|
||
try:
|
||
if ruta == "/api/escanear":
|
||
return self._json({"lanzado": lanzar_escaneo(d["raiz"])})
|
||
if ruta == "/api/elegir-carpeta":
|
||
try:
|
||
que = "Elige la tarjeta o la carpeta con el material nuevo" if d.get("para") == "traer" else "Elige la carpeta del cliente"
|
||
return self._json({"ruta": elegir_carpeta(que)})
|
||
except ValueError as e:
|
||
return self._json({"error": str(e)}, 409)
|
||
if ruta == "/api/raiz/quitar":
|
||
try:
|
||
return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")), bool(d.get("descartar_cola"))))
|
||
except ErrorCola as e:
|
||
return self._json({"error": str(e), **e.datos}, 409)
|
||
except ValueError as e:
|
||
return self._json({"error": str(e)}, 409)
|
||
if ruta == "/api/raices":
|
||
choque = _anidadas(d.get("raices") or [])
|
||
if choque:
|
||
return self._json({"error": f"«{choque[1]}» está dentro de «{choque[0]}», que ya está en la lista. "
|
||
"Dos carpetas anidadas se pisan el inventario: deja solo una de las dos."}, 400)
|
||
CONFIG["raices"] = d["raices"]
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
return self._json({"ok": True})
|
||
if ruta == "/api/relojes":
|
||
r = corregir_reloj(d.get("raiz") or "", d.get("dir") or "", d.get("fuente") or "", d.get("fecha") or "")
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/relojes/deshacer":
|
||
r = deshacer_reloj(d.get("raiz") or "", d.get("grupo") or "")
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/banco/modo":
|
||
k = _clave(d.get("raiz") or "")
|
||
modo = d.get("modo")
|
||
if not raiz_cfg(k) or modo not in ("temas", "fecha") or not d.get("nombre"):
|
||
return self._json({"error": "carpeta, banco o forma de ordenar desconocida"}, 400)
|
||
CONFIG.setdefault("bancos", {}).setdefault(k.lower(), {})[R.norm(d["nombre"])] = modo
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
bitacora({"op": "banco_modo", "raiz": k, "banco": d["nombre"], "modo": modo})
|
||
olvidar_plan(k)
|
||
return self._json({"ok": True, "modo": modo})
|
||
if ruta == "/api/raiz/tipo":
|
||
# el tipo de trabajo de una carpeta, y el de su cliente con él: así no vuelven a quedar distintos
|
||
k = _clave(d.get("raiz") or "")
|
||
rc = raiz_cfg(k)
|
||
tipo = d.get("tipo")
|
||
if not rc or tipo not in PLANTILLAS:
|
||
return self._json({"error": "carpeta o tipo de trabajo desconocido"}, 400)
|
||
antes = rc.get("tipo", "contenido")
|
||
rc["tipo"] = tipo
|
||
CONFIG.setdefault("clientes", {}).setdefault(rc["cliente"], {})["tipo"] = tipo
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
bitacora({"op": "tipo", "raiz": k, "tipo": tipo, "antes": antes})
|
||
olvidar_plan(k)
|
||
return self._json({"ok": True, "tipo": tipo})
|
||
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", "ahorro_minimo", "simultaneas", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
|
||
"horario", "pausar_si_edito", "aligerar", "avisar"},
|
||
"respaldo": {"destino"}, "actualizaciones": {"auto"}, "escaneo": {"profundo"}}
|
||
for k, v in d.items():
|
||
if k in permitidas and isinstance(v, dict):
|
||
CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]})
|
||
if isinstance(d.get("actualizaciones"), dict) and "auto" in d["actualizaciones"]:
|
||
CONFIG["actualizaciones"]["elegido"] = True # lo decidió una persona, no un valor por defecto
|
||
_sufijos_conversion()
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
return self._json({"ok": True, "config": CONFIG})
|
||
if ruta == "/api/diccionario":
|
||
tok = R.norm(d.get("token", ""))
|
||
cub = d.get("cubeta")
|
||
if not tok:
|
||
return self._json({"error": "token vacío"}, 400)
|
||
if cub and tok in R.NOMBRES_DE_LA_APP:
|
||
return self._json({"error": f"«{d.get('token')}» es una carpeta de la propia app: no hay nada que enseñarle"}, 400)
|
||
with LOCK:
|
||
if cub in (None, ""):
|
||
DICC["carpetas"].pop(tok, None)
|
||
else:
|
||
DICC["carpetas"][tok] = cub
|
||
DICC["aprendido"].append({"ts": time.time(), "token": tok, "cubeta": cub})
|
||
guardar_json(DICC_PATH, DICC)
|
||
if cub and d.get("raiz"): # para el ✓ de «Decir qué son» en Inicio
|
||
bitacora({"op": "nombrar", "raiz": _clave(d["raiz"]), "token": tok, "cubeta": cub, "ejemplo": d.get("ejemplo") or tok})
|
||
return self._json({"ok": True})
|
||
if ruta == "/api/regla":
|
||
with LOCK:
|
||
DICC["reglas"] = [r for r in DICC["reglas"] if not (R.norm(r.get("origen", "")) == R.norm(d.get("origen", "")) and r.get("cliente") == d.get("cliente"))]
|
||
if d.get("destino") is not None:
|
||
DICC["reglas"].append({"cliente": d.get("cliente"), "origen": d.get("origen"), "destino": d.get("destino"),
|
||
"cubeta": d.get("cubeta", "REVISAR"), "ts": time.time()})
|
||
guardar_json(DICC_PATH, DICC)
|
||
return self._json({"ok": True, "reglas": DICC["reglas"]})
|
||
if ruta == "/api/plantillas":
|
||
for t, cubs in (d.get("plantillas") or {}).items():
|
||
PLANTILLAS.setdefault(t, {}).update(cubs or {})
|
||
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
|
||
PLANES.clear() # los planes calculados apuntaban a los destinos viejos
|
||
return self._json({"ok": True})
|
||
if ruta == "/api/aplicar":
|
||
raiz = os.path.normpath(d["raiz"])
|
||
plan = PLANES.get(raiz)
|
||
if not plan:
|
||
# el plan se olvidó porque la carpeta cambió (se movió algo, se volvió a escanear): los
|
||
# números de fila que trae la interfaz ya no dicen nada; se pide calcularlo otra vez
|
||
if PLAN_GEN.get(raiz):
|
||
return self._json({"error": "la carpeta cambió desde que se calculó el plan: hay que volver a calcular «Qué va a pasar»", "recalcular": True}, 409)
|
||
return self._json({"error": "primero genera el plan"}, 400)
|
||
ids = set(d.get("ids", []))
|
||
filas = [f for f in plan["filas"] if f["id"] in ids]
|
||
# respetar destinos editados en la interfaz
|
||
for f in filas:
|
||
ed = (d.get("destinos") or {}).get(str(f["id"]))
|
||
if ed:
|
||
nuevo = os.path.normpath(os.path.join(raiz, ed))
|
||
if not dentro_de(raiz, nuevo):
|
||
return self._json({"error": f"el destino «{ed}» queda fuera de la carpeta del cliente"}, 400)
|
||
f["destino"] = nuevo
|
||
res = aplicar(raiz, filas)
|
||
if not res.get("movimientos"):
|
||
olvidar_plan(raiz) # si algo se movió, ya lo olvidó el inventario
|
||
return self._json(res)
|
||
if ruta == "/api/deshacer":
|
||
return self._json(deshacer(d["lote"]))
|
||
if ruta == "/api/actualizacion/buscar":
|
||
actualizador.comprobar()
|
||
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
|
||
if ruta == "/api/actualizacion/instalar":
|
||
ok = actualizador.descargar_y_aplicar(en_hilo=True)
|
||
return self._json({"lanzado": bool(ok), **actualizador.ESTADO})
|
||
if ruta == "/api/acciones/hacer":
|
||
# «Hacer todo lo seguro»: el mismo motor que tenía el asistente, pero lanzado desde la
|
||
# lista de acciones y sin preguntar nada. Deja fuera lo que no debe ir en automático:
|
||
# convertir (tarda horas y hay que elegir formato) y respaldar (necesita otro disco).
|
||
raiz = os.path.normpath(d["raiz"])
|
||
if asistente.ESTADO["activo"]:
|
||
return self._json({"error": "ya hay algo en marcha en esta carpeta"}, 409)
|
||
if ((acciones(raiz).get("escaneo") or {}).get("reglas") or 0) < R.VERSION_REGLAS:
|
||
return self._json({"error": "el inventario de esta carpeta es de una versión anterior: escanéala otra vez antes"}, 409)
|
||
diag = asistente.diagnostico(raiz)
|
||
filas = Plan(raiz_cfg(raiz)).correr()["filas"]
|
||
dec = {p["clave"]: p["defecto"] for p in asistente.pasos(diag, filas)}
|
||
dec["convertir"] = "no"
|
||
dec["respaldo"] = "no"
|
||
dec["carpetas"] = "seguras" # nunca las dudosas sin que alguien las mire
|
||
asistente.ejecutar(raiz, dec, filas, en_hilo=True)
|
||
return self._json({"lanzado": True, "hara": {k: v for k, v in dec.items() if v not in ("no", "dejar")}})
|
||
if ruta == "/api/asistente":
|
||
raiz = os.path.normpath(d["raiz"])
|
||
if asistente.ESTADO["activo"]:
|
||
return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"})
|
||
asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True)
|
||
return self._json({"lanzado": True})
|
||
if ruta == "/api/optimizar/analizar/detener":
|
||
a = ANALISIS.get(os.path.normpath(d["raiz"]))
|
||
if a:
|
||
a["cancelar"] = True
|
||
return self._json({"ok": bool(a)})
|
||
if ruta == "/api/optimizar/analizar":
|
||
raiz = os.path.normpath(d["raiz"])
|
||
p = ANALISIS.get(raiz)
|
||
if p and p["estado"] == "corriendo":
|
||
return self._json({"lanzado": False})
|
||
threading.Thread(target=analizar, args=(raiz, d.get("min_mb", CONFIG["optimizar"]["min_mb"])), daemon=True).start()
|
||
return self._json({"lanzado": True})
|
||
if ruta == "/api/optimizar/encolar":
|
||
o = CONFIG["optimizar"]
|
||
ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"]), d.get("formato", o.get("formato", "h265_10bit")))
|
||
if d.get("iniciar"):
|
||
iniciar_cola()
|
||
return self._json({"ids": ids})
|
||
if ruta == "/api/optimizar/control":
|
||
acc = d.get("accion")
|
||
if acc == "iniciar":
|
||
return self._json({"ok": iniciar_cola()})
|
||
if acc == "detener":
|
||
COLA["cancelar"] = True
|
||
COLA["activa"] = False
|
||
return self._json({"ok": True})
|
||
if acc == "quitar":
|
||
ap = apartar_descartados(d.get("ids", []))
|
||
ids = [i for i in d.get("ids", []) if i not in ap["quedan"]]
|
||
con = db()
|
||
with con:
|
||
con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado','descartado') AND id IN ({','.join('?' * len(ids))})", ids)
|
||
con.close()
|
||
return self._json({"ok": True, "apartados": ap})
|
||
if acc == "primero": # «primero este»: pasa delante de los que esperan
|
||
con = db()
|
||
with con:
|
||
con.execute("UPDATE trabajos SET orden = (SELECT COALESCE(MAX(orden), 0) + 1 FROM trabajos) WHERE id = ? AND estado = 'pendiente'", (int(d.get("id") or 0),))
|
||
con.close()
|
||
return self._json({"ok": True})
|
||
if acc == "reintentar":
|
||
reubicar_trabajos(ids=d.get("ids", []))
|
||
con = db()
|
||
with con:
|
||
con.execute(f"UPDATE trabajos SET estado = 'pendiente', pct = 0, log = '' WHERE estado IN ('error','cancelado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", []))
|
||
con.close()
|
||
return self._json({"ok": True})
|
||
if ruta == "/api/optimizar/verificar":
|
||
return self._json({"resultados": verificar(d.get("ids", []))})
|
||
if ruta == "/api/optimizar/calidad": # la nota VMAF de un convertido: tarda unos segundos
|
||
try:
|
||
return self._json(calidad_de(int(d.get("id") or 0)))
|
||
except (ValueError, TypeError, subprocess.TimeoutExpired) as ex:
|
||
return self._json({"error": str(ex)}, 400)
|
||
if ruta == "/api/optimizar/reemplazar":
|
||
return self._json({"resultados": reemplazar(d.get("ids", []))})
|
||
if ruta == "/api/stock/papelera":
|
||
return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", [])))
|
||
if ruta == "/api/estructura/renombrar":
|
||
return self._json(renombrar_nodo(d.get("tipo") or "contenido", d.get("ruta") or [],
|
||
d.get("nombre", ""), d.get("raiz"), bool(d.get("en_disco"))))
|
||
if ruta == "/api/colores/pintar":
|
||
return self._json(pintar_carpetas(os.path.normpath(d["raiz"])))
|
||
if ruta == "/api/colores/quitar":
|
||
return self._json(despintar_carpetas(os.path.normpath(d["raiz"])))
|
||
if ruta == "/api/vacias/borrar":
|
||
return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", [])))
|
||
if ruta == "/api/duplicados/apartar":
|
||
return self._json(apartar_duplicados(os.path.normpath(d["raiz"])))
|
||
if ruta == "/api/abrir":
|
||
return self._json(abrir_carpeta(d.get("ruta", "")))
|
||
if ruta == "/api/pieza":
|
||
r = nueva_pieza(d["raiz"], d.get("titulo", ""), d.get("anio"), d.get("mes"))
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/entrada/abrir":
|
||
r = abrir_entrada(d["raiz"])
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/entrada/ordenar":
|
||
r = ordenar_entrada(d["raiz"])
|
||
return self._json(r, 409 if r.get("error") else 200)
|
||
if ruta == "/api/entrada/plan":
|
||
r = plan_traer(d["raiz"], d.get("origen", ""), d.get("nombre", ""))
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/entrada/traer":
|
||
r = traer(d["raiz"], d.get("origen", ""), d.get("nombre", ""), d.get("ordenar", True) is not False, en_hilo=True)
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/entrada/cancelar":
|
||
if TRAER.get("estado") == "copiando":
|
||
TRAER["cancelar"] = True
|
||
return self._json({"ok": True})
|
||
if ruta == "/api/automatico/config":
|
||
r = configurar_automatico(bool(d.get("activo")), d.get("hora"))
|
||
return self._json(r, 400 if r.get("error") else 200)
|
||
if ruta == "/api/automatico/ahora":
|
||
r = automatico(en_hilo=True, origen=d.get("origen") or "a mano")
|
||
return self._json(r, 409 if r.get("error") else 200)
|
||
if ruta == "/api/respaldo/copiar":
|
||
_rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", "")))
|
||
if _rp and _rp.get("destino_base"):
|
||
CONFIG.setdefault("respaldo", {})["destino"] = _rp["destino_base"]
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
raiz = os.path.normpath(d["raiz"])
|
||
plan = PLANES_RESPALDO.get(raiz)
|
||
if not plan:
|
||
return self._json({"error": "primero calcula el respaldo"}, 400)
|
||
if RESPALDO["estado"] == "corriendo":
|
||
return self._json({"error": "ya hay una copia en curso"}, 400)
|
||
threading.Thread(target=copiar_respaldo, args=(raiz, plan), daemon=True).start()
|
||
return self._json({"ok": True})
|
||
if ruta == "/api/respaldo/detener":
|
||
RESPALDO["cancelar"] = True
|
||
return self._json({"ok": True})
|
||
return self._json({"error": "no existe"}, 404)
|
||
except Exception as ex: # noqa
|
||
import traceback
|
||
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
|
||
|
||
|
||
# ==========================================================================
|
||
# Línea de comandos: organizar una carpeta con un solo comando
|
||
# ==========================================================================
|
||
|
||
AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando
|
||
|
||
OrganizadorLabstream.exe abre OrganiLAB en su ventana
|
||
OrganizadorLabstream.exe instalar lo instala en su sitio: carpeta de programas, menú Inicio y Escritorio (desinstalar lo quita)
|
||
OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra
|
||
de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer
|
||
OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar)
|
||
OrganizadorLabstream.exe organizar "ruta" [opciones]
|
||
--cliente "Nombre" nombre del cliente (por defecto, el de la carpeta)
|
||
--tipo contenido|pauta|proyecto|interno (por defecto contenido)
|
||
--solo-alta aplica solo los movimientos de confianza alta
|
||
--todo aplica también los de confianza baja y los cosméticos
|
||
--simular solo muestra el plan, no mueve nada
|
||
--si no pregunta: aplica directamente
|
||
OrganizadorLabstream.exe escanear "ruta" solo inventaría (para ver el mapa en la interfaz)
|
||
OrganizadorLabstream.exe stock "ruta" [--meses 3] [--todo-tipo] [--si] aparta el stock de Envato viejo a _Para borrar/stock
|
||
OrganizadorLabstream.exe respaldo "ruta" "destino" [--con-raw] [--con-stock] copia compacta a otro disco
|
||
OrganizadorLabstream.exe deshacer LOTE deshace un lote de la bitácora
|
||
OrganizadorLabstream.exe actualizar [--si] busca versión nueva en Labstream OS y la instala
|
||
OrganizadorLabstream.exe duplicados "ruta" [--si] lista los duplicados; con --si los junta en subcarpetas Duplicados
|
||
OrganizadorLabstream.exe automatico pone al día todas las carpetas registradas: lee cada una y hace solo lo seguro
|
||
(es lo que lanza Windows cada día si se enciende el orden automático)
|
||
|
||
La app NUNCA borra: lo que sobra va a _Para borrar dentro de la misma carpeta (la borra una persona a mano) y todo queda en datos\\bitacora.jsonl.
|
||
"""
|
||
|
||
|
||
def _gb(b):
|
||
b = b or 0
|
||
if b >= 1e12:
|
||
return f"{b / 1e12:.2f} TB"
|
||
return f"{b / 1e9:.1f} GB" if b >= 1e8 else f"{b / 1e6:.0f} MB"
|
||
|
||
|
||
def _opcion(args, nombre, defecto=None):
|
||
if nombre in args:
|
||
i = args.index(nombre)
|
||
return args[i + 1] if i + 1 < len(args) else defecto
|
||
return defecto
|
||
|
||
|
||
def _confirmar(pregunta, auto):
|
||
if auto:
|
||
return True
|
||
try:
|
||
return input(pregunta + " [S/n] ").strip().lower() in ("", "s", "si", "sí", "y")
|
||
except EOFError:
|
||
return False
|
||
|
||
|
||
def cli_escanear(ruta):
|
||
ruta = os.path.normpath(ruta)
|
||
print(f"Escaneando {ruta} …", flush=True)
|
||
escanear(ruta)
|
||
p = PROGRESO[ruta]
|
||
if p["estado"] != "listo":
|
||
print("ERROR al escanear:", p.get("error"))
|
||
return False
|
||
print(f" {p['n']:,} archivos · {_gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s")
|
||
return True
|
||
|
||
|
||
def cli_raiz(ruta, args):
|
||
"""asegura que la carpeta esté registrada como raíz (la crea si es nueva)"""
|
||
ruta = os.path.normpath(ruta)
|
||
if not os.path.isdir(ruta):
|
||
print("No existe la carpeta:", ruta)
|
||
return None
|
||
r = raiz_cfg(ruta)
|
||
if not r:
|
||
nombre = os.path.basename(ruta.rstrip("\\/")) or ruta
|
||
cliente = _opcion(args, "--cliente") or next((c for c, i in CONFIG["clientes"].items()
|
||
if any(R.norm(a) == R.norm(nombre) for a in i.get("alias", []) + [c])), nombre)
|
||
tipo = _opcion(args, "--tipo") or CONFIG["clientes"].get(cliente, {}).get("tipo", "contenido")
|
||
r = {"ruta": ruta, "cliente": cliente, "tipo": tipo}
|
||
CONFIG["raices"].append(r)
|
||
CONFIG["clientes"].setdefault(cliente, {"tipo": tipo, "alias": [nombre]})
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
print(f"Carpeta nueva registrada como raíz: cliente «{cliente}», tipo {tipo}")
|
||
return r
|
||
|
||
|
||
def cli_organizar(ruta, args):
|
||
r = cli_raiz(ruta, args)
|
||
if not r or not cli_escanear(r["ruta"]):
|
||
return 1
|
||
plan = Plan(r).correr()
|
||
filas = plan["filas"]
|
||
if not filas:
|
||
print("Nada que mover: la carpeta ya está en orden.")
|
||
return 0
|
||
if "--todo" in args:
|
||
elegidas = filas
|
||
elif "--solo-alta" in args:
|
||
elegidas = [f for f in filas if f["confianza"] == "alta"]
|
||
else:
|
||
elegidas = [f for f in filas if f["marcado"]]
|
||
print(f"\nPLAN: {len(filas)} movimientos propuestos; se aplicarían {len(elegidas)} "
|
||
f"({_gb(sum(f['bytes'] for f in elegidas))}, {sum(f['n'] for f in elegidas):,} archivos)\n")
|
||
for f in sorted(elegidas, key=lambda f: -f["bytes"])[:60]:
|
||
n = f" [{len(f['archivos'])} archivos]" if f["archivos"] else ""
|
||
print(f" {f['confianza']:5} {f['cubeta']:10} {_gb(f['bytes']):>8} {f['origen_rel']}{n}\n{'':28}-> {f['destino_rel']} ({f['motivo'][:70]})")
|
||
if len(elegidas) > 60:
|
||
print(f" … y {len(elegidas) - 60} más")
|
||
omitidas = [f for f in filas if f not in elegidas]
|
||
if omitidas:
|
||
print(f"\n Sin aplicar (confianza baja o cosméticos): {len(omitidas)}. Con --todo se aplican; con la interfaz se revisan uno a uno.")
|
||
for a in plan["avisos"]:
|
||
print(" AVISO:", a)
|
||
if "--simular" in args:
|
||
print("\nSimulación: no se movió nada.")
|
||
return 0
|
||
if not elegidas:
|
||
return 0
|
||
if not _confirmar("\n¿Aplicar estos movimientos? (los archivos no se renombran; lo sobrante va a _Para borrar)", "--si" in args):
|
||
print("Cancelado. No se movió nada.")
|
||
return 0
|
||
res = aplicar(r["ruta"], elegidas)
|
||
print(f"\nHecho: lote {res['lote']} · {res['movimientos']} movimientos · {len(res['errores'])} errores")
|
||
for e in res["errores"][:20]:
|
||
print(" ERROR:", e)
|
||
print(f"Para deshacer: OrganizadorLabstream.exe deshacer {res['lote']}")
|
||
return 0 if not res["errores"] else 2
|
||
|
||
|
||
def cli_stock(ruta, args):
|
||
r = cli_raiz(ruta, args)
|
||
if not r or not cli_escanear(r["ruta"]):
|
||
return 1
|
||
meses = int(_opcion(args, "--meses", CONFIG["optimizar"]["meses_minimos"]))
|
||
tipos = ["Video", "Audio", "Imagenes"] if "--todo-tipo" in args else ["Video"]
|
||
s = stock_viejo(r["ruta"], meses, tipos)
|
||
print(f"\nStock de Envato en proyectos anteriores a {s['tope']}: {s['n']} archivos · {_gb(s['bytes'])}")
|
||
for g in s["grupos"][:40]:
|
||
print(f" {_gb(g['bytes']):>8} {g['carpeta']} ({g['n']}: {', '.join(g['ejemplos'])[:80]})")
|
||
if not s["n"] or "--simular" in args:
|
||
return 0
|
||
if not _confirmar("¿Apartar todo a _Para borrar/stock? (reversible)", "--si" in args):
|
||
return 0
|
||
res = stock_a_papelera(r["ruta"], [x for g in s["grupos"] for x in g["rutas"]])
|
||
print(f"Hecho: lote {res['lote']} · {res['movimientos']} archivos apartados. Están en _Para borrar\\stock: revísalos y bórralos a mano.")
|
||
return 0
|
||
|
||
|
||
def cli_respaldo(ruta, destino, args):
|
||
r = cli_raiz(ruta, args)
|
||
if not r or not cli_escanear(r["ruta"]):
|
||
return 1
|
||
plan = plan_respaldo(r["ruta"], destino, True, "--con-raw" in args, "--con-stock" not in args)
|
||
print(f"\nRespaldo de «{plan['cliente']}» → {plan['base']}: {plan['n']:,} archivos · {_gb(plan['bytes'])}")
|
||
print(" por tipo:", ", ".join(f"{t['tipo']} {t['n']} ({_gb(t['bytes'])})" for t in plan["por_tipo"]))
|
||
if plan["raw_omitidos"]["n"]:
|
||
print(f" NO se copian {plan['raw_omitidos']['n']} originales de cámara sin convertir ({_gb(plan['raw_omitidos']['bytes'])}); conviértelos antes o usa --con-raw")
|
||
if "--simular" in args or not _confirmar("¿Copiar?", "--si" in args):
|
||
return 0
|
||
hilo = threading.Thread(target=copiar_respaldo, args=(r["ruta"], plan), daemon=True)
|
||
hilo.start()
|
||
ultimo = -1
|
||
while hilo.is_alive():
|
||
time.sleep(2)
|
||
pct = int(RESPALDO["bytes"] / RESPALDO["bytes_total"] * 100) if RESPALDO["bytes_total"] else 0
|
||
if pct != ultimo:
|
||
print(f" {pct:3d}% {RESPALDO['hechos']}/{RESPALDO['total']} {RESPALDO['actual'][-60:]}", flush=True)
|
||
ultimo = pct
|
||
print(f"\n{RESPALDO['estado']}: {RESPALDO['copiados']} copiados, {RESPALDO['saltados']} ya estaban, {len(RESPALDO['errores'])} errores. Índice en {plan['base']}\\INDICE.csv")
|
||
return 0
|
||
|
||
|
||
def cli(args):
|
||
cmd = args[0].lower() if args else ""
|
||
resto = args[1:]
|
||
if cmd in ("-h", "--help", "ayuda", "/?"):
|
||
print(AYUDA)
|
||
return 0
|
||
if os.path.isdir(args[0]) and cmd not in ("organizar", "escanear", "stock", "respaldo", "deshacer", "duplicados", "asistente"):
|
||
return asistente.cli(args[0], resto) # arrastraron una carpeta encima del .exe
|
||
if cmd == "asistente" and resto:
|
||
return asistente.cli(resto[0], resto[1:])
|
||
if cmd in ("actualizar", "actualizacion"):
|
||
return actualizador.cli(resto)
|
||
if cmd == "organizar" and resto:
|
||
return cli_organizar(resto[0], resto[1:])
|
||
if cmd == "escanear" and resto:
|
||
r = cli_raiz(resto[0], resto[1:])
|
||
return 0 if r and cli_escanear(r["ruta"]) else 1
|
||
if cmd == "stock" and resto:
|
||
return cli_stock(resto[0], resto[1:])
|
||
if cmd == "respaldo" and len(resto) >= 2:
|
||
return cli_respaldo(resto[0], resto[1], resto[2:])
|
||
if cmd == "deshacer" and resto:
|
||
res = deshacer(resto[0])
|
||
print(f"Deshechos {res['deshechos']} movimientos · {len(res['errores'])} errores")
|
||
return 0
|
||
if cmd == "duplicados" and resto:
|
||
d = duplicados(os.path.normpath(resto[0]))
|
||
print(f"{len(d['grupos'])} grupos · {d['n']} copias · {d['bytes'] / 1e9:.1f} GB ({d['identicos']} idénticos, {d['versiones']} versiones MP4/MOV)")
|
||
for g in d["grupos"][:40]:
|
||
print(f" [{g['tipo']}] {g['nombre']} queda: {g['queda']} → {len(g['mueven'])} a {g['destino']}")
|
||
if "--si" in resto and d["grupos"]:
|
||
print(apartar_duplicados(os.path.normpath(resto[0]), d["grupos"]))
|
||
return 0
|
||
print(AYUDA)
|
||
return 1
|
||
|
||
|
||
def _asegurar_consola():
|
||
"""El .exe es de ventana (sin consola). Para los comandos y el arrastrar-carpeta hace falta una:
|
||
se engancha a la del padre (cmd/PowerShell) o se abre una nueva."""
|
||
if not CONGELADO or os.name != "nt":
|
||
return
|
||
try:
|
||
import ctypes
|
||
k = ctypes.windll.kernel32
|
||
if k.GetConsoleWindow(): # ya hay consola
|
||
return
|
||
h = k.GetStdHandle(-11)
|
||
if h and h != -1 and k.GetFileType(h) in (1, 3): # salida a archivo o tubería: se respeta
|
||
return
|
||
if not k.AttachConsole(-1):
|
||
k.AllocConsole()
|
||
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace", buffering=1)
|
||
sys.stderr = sys.stdout
|
||
sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace")
|
||
k.SetConsoleTitleW("OrganiLAB")
|
||
except Exception: # noqa
|
||
pass
|
||
|
||
|
||
SOLTADOS = {"ts": 0, "rutas": []}
|
||
SESION_PATH = os.path.join(DATOS, "sesion.json") # qué copia tiene abiertos estos datos, y en qué puerto atiende
|
||
_CANDADO = [] # el de «una sola copia abierta»; vive lo que el proceso
|
||
SILENCIO_NAVEGADOR = 180 # segundos sin que la página pregunte para dar el navegador por cerrado
|
||
RELEVO = [] # el .exe que toma el relevo al salir (tras instalarse en su sitio)
|
||
|
||
|
||
def _al_soltar(rutas):
|
||
"""Lo que se acaba de soltar en la ventana, con su ruta completa: la ventana la sabe, la interfaz no."""
|
||
if rutas:
|
||
SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas])
|
||
|
||
|
||
def soltado(nombre, espera=2.5):
|
||
"""Ruta completa de lo que se acaba de soltar en la ventana. Espera un poco porque el aviso de la ventana
|
||
y la pregunta de la interfaz no llegan siempre en el mismo orden."""
|
||
fin = time.time() + espera
|
||
while time.time() < fin:
|
||
if time.time() - SOLTADOS["ts"] < 8:
|
||
for r in SOLTADOS["rutas"]:
|
||
if not nombre or os.path.basename(r.rstrip("\\/")).lower() == nombre.lower():
|
||
return r
|
||
time.sleep(0.1)
|
||
return None
|
||
|
||
|
||
def en_marcha():
|
||
"""Qué está haciendo el programa ahora mismo, dicho en llano, o "" si no hace nada. Mientras haya algo,
|
||
cerrar la ventana no lo corta."""
|
||
que = actualizador.tarea_en_marcha()
|
||
if not que and APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
|
||
que = "se están moviendo archivos"
|
||
if not que and TRAER.get("estado") in ("copiando", "ordenando"):
|
||
que = "se está trayendo material de una tarjeta"
|
||
if not que and AUTO.get("activo"):
|
||
que = "el orden automático está poniendo al día las carpetas"
|
||
return que
|
||
|
||
|
||
def avance():
|
||
"""Cuánto lleva lo que está en marcha: de 0 a 1, -1 si no se puede saber y None si no hay nada. Es lo que
|
||
se ve en el botón de la barra de tareas."""
|
||
if COLA["activa"]:
|
||
try:
|
||
con = db()
|
||
espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0]
|
||
ids = list(COLA["actuales"])
|
||
pcts = [r[0] or 0 for r in con.execute(f"SELECT pct FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)] if ids else []
|
||
con.close()
|
||
except sqlite3.Error:
|
||
return -1
|
||
total = COLA["hechos"] + len(pcts) + espera
|
||
return (COLA["hechos"] + sum(pcts) / 100) / total if total else -1
|
||
if RESPALDO.get("estado") == "corriendo":
|
||
return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1
|
||
if TRAER.get("estado") == "copiando":
|
||
return TRAER["bytes"] / TRAER["bytes_total"] if TRAER.get("bytes_total") else -1
|
||
if AUTO.get("activo"):
|
||
return AUTO["hechas"] / AUTO["total"] if AUTO.get("total") else -1
|
||
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
|
||
return APLICANDO["unidades"] / APLICANDO["unidades_total"] if APLICANDO.get("unidades_total") else APLICANDO["hechas"] / APLICANDO["total"]
|
||
if asistente.ESTADO.get("activo"):
|
||
return asistente.ESTADO["pct"] / 100 if asistente.ESTADO.get("total") else -1
|
||
for a in list(ANALISIS.values()):
|
||
if (a or {}).get("estado") == "corriendo":
|
||
return a["hechos"] / a["total"] if a.get("total") else -1
|
||
if any((p or {}).get("estado") == "corriendo" for p in list(PROGRESO.values())):
|
||
return -1
|
||
if actualizador.ESTADO.get("estado") == "descargando":
|
||
return (actualizador.ESTADO.get("pct") or 0) / 100
|
||
return None
|
||
|
||
|
||
def _marca():
|
||
"""Un número que identifica esta instalación por su carpeta de datos."""
|
||
return (zlib.crc32(os.path.normcase(os.path.abspath(DATOS)).encode("utf-8")) & 0x7FFFFFFF) or 1
|
||
|
||
|
||
def _soy_la_unica():
|
||
"""False si ya hay otra copia del programa abierta sobre estos mismos datos: dos a la vez se pisarían el
|
||
inventario, y la segunda daría por huérfanas las conversiones que la primera tiene en marcha."""
|
||
if os.name != "nt":
|
||
return True
|
||
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
||
k.CreateMutexW.argtypes, k.CreateMutexW.restype = [ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p], ctypes.c_void_p
|
||
h = k.CreateMutexW(None, False, f"OrganiLAB-{_marca():08x}")
|
||
if h and ctypes.get_last_error() == 183: # ya existía
|
||
return False
|
||
_CANDADO.append(h)
|
||
return True
|
||
|
||
|
||
def _limpiar_restos():
|
||
"""Lo que dejó el .exe de PyInstaller de las versiones hasta la 2.x: su carpeta _internal, que ya no usa
|
||
nadie (el motor de ahora va en la carpeta runtime)."""
|
||
viejo = os.path.join(BASE, "_internal")
|
||
if CONGELADO and not getattr(sys, "frozen", False) and os.path.isfile(os.path.join(viejo, "base_library.zip")):
|
||
shutil.rmtree(viejo, ignore_errors=True)
|
||
|
||
|
||
def _vigilar(v, fin):
|
||
"""Mientras la ventana existe: lleva el avance al botón de la barra de tareas, avisa cuando termina algo
|
||
largo y, si la ventana se cerró con trabajo pendiente, cierra el programa en cuanto acaba."""
|
||
antes, desde = None, 0.0
|
||
while not fin.wait(1.0):
|
||
try:
|
||
f = avance()
|
||
except Exception: # noqa
|
||
f = None
|
||
if f is None:
|
||
if antes is not None:
|
||
v.progreso(None)
|
||
if time.time() - desde > 20 and not v.oculta:
|
||
v.avisar()
|
||
if v.oculta and not en_marcha():
|
||
v.cerrar()
|
||
else:
|
||
if antes is None:
|
||
desde = time.time()
|
||
if not v.oculta:
|
||
v.progreso(f)
|
||
antes = f
|
||
|
||
|
||
def _recien_abierta(v):
|
||
"""Con la interfaz ya a la vista: si el programa está dado de alta en Windows, pone al día su versión y
|
||
sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece instalarlo: copiarlo a
|
||
su sitio definitivo, darlo de alta y seguir desde allí."""
|
||
try:
|
||
import ventana
|
||
import instalar
|
||
if instalar.al_dia(BASE, VERSION):
|
||
return
|
||
oferta = CONFIG.get("instalacion") or {}
|
||
if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE):
|
||
return
|
||
if instalar.instalado() and not instalar.instalado_aqui(BASE):
|
||
return # ya hay otro instalado en este equipo: esta copia va por libre
|
||
CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
time.sleep(1.5)
|
||
if not instalar.hay_que_mudar(BASE): # ya está en su sitio: solo falta darlo de alta
|
||
if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n"
|
||
"Se quita desde Configuración › Aplicaciones.", ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES:
|
||
instalar.instalar(BASE, VERSION)
|
||
v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.")
|
||
return
|
||
if v.mensaje("¿Instalar OrganiLAB en este equipo?\n\nSe copia a tu carpeta de programas, queda en el menú Inicio y en el "
|
||
"Escritorio, y se quita desde Configuración › Aplicaciones. La carpeta desde la que lo has abierto ya no hará falta.",
|
||
ventana.MB_YESNO | ventana.MB_ICONQUESTION) != ventana.IDYES:
|
||
return
|
||
v.evaluar("modal(" + json.dumps("Instalando OrganiLAB…") + ", " + json.dumps(
|
||
'<p><span class="spin"></span> Copiando el programa a su sitio definitivo. Tarda unos segundos; al terminar se abre solo desde allí.</p>') + ", null)")
|
||
try:
|
||
r = instalar.poner(BASE, VERSION, avance=v.progreso)
|
||
except Exception as ex: # noqa
|
||
v.progreso(None)
|
||
v.evaluar("document.querySelector('#modal').classList.remove('on')")
|
||
v.mensaje(f"No se pudo instalar: {ex}\n\nEl programa sigue funcionando desde esta carpeta.", ventana.MB_OK | ventana.MB_ICONWARNING)
|
||
return
|
||
v.progreso(None)
|
||
v.mensaje("OrganiLAB quedó instalado.\n\nAhora se cierra y se abre desde su sitio. La carpeta desde la que lo abriste "
|
||
"ya no hace falta: puedes borrarla.")
|
||
RELEVO.append(os.path.join(r["base"], "OrganizadorLabstream.exe"))
|
||
v.cerrar()
|
||
except Exception as ex: # noqa
|
||
print("no se pudo dar de alta en Windows:", ex)
|
||
|
||
|
||
def _ventana(url):
|
||
"""Abre la ventana del programa y no vuelve hasta que se cierra. Devuelve False si este Windows no puede
|
||
abrirla (le falta WebView2): quien llama abre entonces la interfaz en el navegador."""
|
||
global VENTANA
|
||
try:
|
||
import ventana
|
||
import instalar
|
||
ok, detalle = ventana.disponible(BASE)
|
||
if not ok:
|
||
print("Sin ventana propia:", detalle)
|
||
return False
|
||
v = ventana.Ventana(url, titulo="OrganiLAB", icono=os.path.join(BASE, "static", "organilab.ico"),
|
||
perfil=os.path.join(DATOS, "ventana"), memoria=os.path.join(DATOS, "ventana.json"),
|
||
base=BASE, marca=_marca(), relanzar=instalar.orden(BASE),
|
||
depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR")))
|
||
v.al_cerrar, v.al_soltar = en_marcha, _al_soltar
|
||
v.al_abrir = lambda: _recien_abierta(v)
|
||
fin = threading.Event()
|
||
threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start()
|
||
VENTANA = v
|
||
try:
|
||
abierta = v.mostrar()
|
||
finally:
|
||
VENTANA = None
|
||
fin.set()
|
||
if not abierta:
|
||
print("La ventana no llegó a abrirse:", v.fallo)
|
||
return abierta
|
||
except Exception as ex: # noqa
|
||
print("Sin ventana propia:", ex)
|
||
return False
|
||
|
||
|
||
def _atender_navegador():
|
||
"""Sin ventana no hay nada que cerrar: el programa sigue mientras la página del navegador pregunte (lo hace
|
||
cada pocos segundos) y se va cuando lleva tres minutos callada y no queda trabajo en marcha."""
|
||
ULTIMA_PETICION[0] = time.time()
|
||
try:
|
||
while time.time() - ULTIMA_PETICION[0] < SILENCIO_NAVEGADOR or en_marcha():
|
||
time.sleep(2)
|
||
except KeyboardInterrupt:
|
||
pass
|
||
|
||
|
||
def main():
|
||
global PUERTO
|
||
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||
if CONGELADO and "--puente" not in sys.argv:
|
||
import instalar
|
||
instalada = instalar.mudado(BASE)
|
||
if instalada: # desde esta carpeta ya se instaló el programa: se abre el instalado
|
||
if sys.argv[1:]:
|
||
sys.exit(subprocess.call([instalada] + sys.argv[1:]))
|
||
subprocess.Popen([instalada], cwd=os.path.dirname(instalada), close_fds=True)
|
||
return
|
||
if args_tmp and args_tmp[0].lower() in ("instalar", "desinstalar") and "--puente" not in sys.argv:
|
||
import instalar # hablan con cuadros de mensaje: no hace falta consola
|
||
if args_tmp[0].lower() == "instalar" and instalar.hay_que_mudar(BASE) and not _soy_la_unica():
|
||
import ventana # la mudanza se lleva los datos: con el programa abierto se quedarían a medias
|
||
ctypes.windll.user32.MessageBoxW(None, "Cierra OrganiLAB antes de instalarlo.", "OrganiLAB", ventana.MB_OK | ventana.MB_ICONINFORMATION)
|
||
sys.exit(1)
|
||
sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv))
|
||
if args_tmp and args_tmp[0].lower() == "automatico" and "--puente" not in sys.argv:
|
||
sys.exit(automatico_por_comando()) # lo lanza la tarea programada: sin consola ni ventana
|
||
if args_tmp and "--puente" not in sys.argv:
|
||
_asegurar_consola()
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception: # noqa
|
||
pass
|
||
if "--puente" in sys.argv: # puente con Resolve, como lo llamaban los .exe hasta la 2.x
|
||
import resolve_bridge
|
||
sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1]))
|
||
args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")]
|
||
if args:
|
||
if (CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True
|
||
and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?")):
|
||
actualizador.comprobar_en_segundo_plano()
|
||
codigo = cli(args)
|
||
d = actualizador.ESTADO.get("disponible")
|
||
if d:
|
||
print(f"\nHay una versión nueva del Organizador ({d['version']}). Para instalarla: OrganizadorLabstream.exe actualizar")
|
||
if CONGELADO and "--si" not in args and sys.stdin and sys.stdin.isatty():
|
||
try:
|
||
input("\nPulsa Enter para cerrar.")
|
||
except EOFError:
|
||
pass
|
||
sys.exit(codigo)
|
||
sin_ventana = "--sin-navegador" in sys.argv or "--navegador" in sys.argv
|
||
if not _soy_la_unica():
|
||
import ventana
|
||
if not sin_ventana and ventana.traer_al_frente(_marca()):
|
||
return # ya estaba abierto: se enseña esa ventana y nada más
|
||
otra = cargar_json(SESION_PATH, {}).get("puerto")
|
||
print("Ya hay un OrganiLAB abierto con estos datos" + (f", en el puerto {otra}." if otra else "."))
|
||
if otra and "--sin-navegador" not in sys.argv:
|
||
webbrowser.open(f"http://127.0.0.1:{otra}/") # esa copia no tiene ventana: va en el navegador
|
||
return
|
||
h = herramientas()
|
||
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
|
||
print("BRAW:", "se leen con el decodificador de Blackmagic" if h["braw"]["ok"]
|
||
else "falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve; solo hace falta para BRAW)")
|
||
for resto in os.listdir(DATOS): # fichas de trabajo que dejó una conversión cortada (o el guion de Resolve, que no las borraba)
|
||
if resto.endswith((".json", ".cancelar")) and resto.startswith(("resolve_lote_", "braw_")):
|
||
try:
|
||
os.remove(os.path.join(DATOS, resto))
|
||
except OSError:
|
||
pass
|
||
huerfanas = recuperar_huerfanas()
|
||
threading.Thread(target=lambda: (cerrar_conversores_sueltos(), reubicar_trabajos(), repasar_verificados(), braws_al_lector()),
|
||
daemon=True).start()
|
||
if huerfanas:
|
||
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
|
||
try:
|
||
srv = ServidorLocal(("127.0.0.1", PUERTO), Manejador)
|
||
except OSError:
|
||
if os.environ.get("ORGANIZADOR_PUERTO"):
|
||
print(f"El puerto {PUERTO} está ocupado por otro programa.")
|
||
sys.exit(1)
|
||
# el puerto de siempre lo tiene otro programa (u otro OrganiLAB con otros datos): se toma uno libre,
|
||
# que a la ventana le da igual cuál sea
|
||
srv = ServidorLocal(("127.0.0.1", 0), Manejador)
|
||
PUERTO = srv.server_address[1]
|
||
url = f"http://127.0.0.1:{PUERTO}/"
|
||
guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": PUERTO})
|
||
if sys.stdout:
|
||
print(f"OrganiLAB {VERSION} en {url}")
|
||
if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True:
|
||
actualizador.comprobar_en_segundo_plano()
|
||
try:
|
||
if "--sin-navegador" in sys.argv: # solo el servidor (pruebas y desarrollo)
|
||
try:
|
||
srv.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
return
|
||
hilo = threading.Thread(target=srv.serve_forever, daemon=True)
|
||
hilo.start()
|
||
threading.Thread(target=_limpiar_restos, daemon=True).start()
|
||
if "--navegador" in sys.argv:
|
||
webbrowser.open(url)
|
||
try:
|
||
hilo.join()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
return
|
||
if not _ventana(url):
|
||
webbrowser.open(url)
|
||
_atender_navegador()
|
||
while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir
|
||
time.sleep(1)
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
if RELEVO: # se acaba de instalar en su sitio: sigue desde allí
|
||
subprocess.Popen([RELEVO[0]], cwd=os.path.dirname(RELEVO[0]), close_fds=True)
|
||
finally:
|
||
try:
|
||
os.remove(SESION_PATH)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|