Cotejado archivo por archivo cada disco con el inventario: el escaneo estaba completo, pero el inventario apuntaba la carpeta con las mayúsculas del plan (1.039 fotos en Vía Sumapaz, tres carpetas donde hay una), un proxy apartado en pleno escaneo quedó dos veces, y los 11 verificados de Línea Gene se habían vuelto a encolar con la 3.7. Ahora la carpeta se escribe como en el disco y se recuenta donde quedó al final, el escaneo vuelve a recorrer si la app movió algo mientras, y al reenganchar se quita la conversión que sobra. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8407 líneas
441 KiB
Python
8407 líneas
441 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.8.1"
|
||
|
||
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:
|
||
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}
|
||
|
||
|
||
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)
|
||
|
||
|
||
def correr_ffmpeg(t):
|
||
con = db()
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
con.close()
|
||
dur = (f["dur"] if f else 0) or 0
|
||
fps = (f["fps"] if f else 30) or 30
|
||
formato = formato_de(t)
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
motor, pega = _motor_de(formato, t["chroma"] or "420", f["ancho"] if f else 0, f["alto"] if f else 0)
|
||
if not motor:
|
||
set_trabajo(t["id"], estado="error", log=pega)
|
||
return
|
||
salida = t["salida"]
|
||
if _salida_ajena(t):
|
||
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
|
||
return
|
||
os.makedirs(os.path.dirname(salida), exist_ok=True)
|
||
tmp = salida + ".parcial" + fm["ext"]
|
||
video, _ = _args_video(t, fm, motor, fps)
|
||
contenedor, audio = _args_salida(fm, f)
|
||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
|
||
cmd += video + ["-map_metadata", "0"] + contenedor + audio
|
||
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
|
||
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
|
||
try:
|
||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
|
||
except OSError as ex:
|
||
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}")
|
||
return
|
||
ultimo = 0
|
||
for linea in p.stdout:
|
||
if COLA["cancelar"]:
|
||
p.kill()
|
||
break
|
||
if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="):
|
||
try:
|
||
us = int(linea.split("=")[1])
|
||
if linea.startswith("out_time_ms="):
|
||
us = us # ffmpeg reporta microsegundos en ambas claves
|
||
pct = min(99.0, us / 1e6 / dur * 100) if dur else 0
|
||
if time.time() - ultimo > 1:
|
||
set_trabajo(t["id"], pct=pct)
|
||
ultimo = time.time()
|
||
except ValueError:
|
||
pass
|
||
err = p.stderr.read()
|
||
p.wait()
|
||
if COLA["cancelar"]:
|
||
set_trabajo(t["id"], estado="cancelado", pct=0)
|
||
_quitar_parcial(tmp)
|
||
return
|
||
if p.returncode != 0 or not os.path.exists(tmp):
|
||
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
|
||
_quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
|
||
return
|
||
os.replace(tmp, salida)
|
||
try:
|
||
st = os.stat(t["ruta"])
|
||
os.utime(salida, (st.st_atime, st.st_mtime))
|
||
except OSError:
|
||
pass
|
||
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time())
|
||
|
||
|
||
def correr_braw(t):
|
||
"""Un BRAW. Lo lee el decodificador de Blackmagic en un proceso aparte (braw.py), que le va pasando los
|
||
cuadros ya revelados a ffmpeg; el codificador y la calidad son los de cualquier otro clip. No hace
|
||
falta tener Resolve abierto, y si lo está no se le toca nada."""
|
||
formato = formato_de(t)
|
||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||
dec = decodificador_braw()
|
||
if not dec.get("ok"):
|
||
set_trabajo(t["id"], estado="error", log=dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||
return
|
||
salida = t["salida"]
|
||
if _salida_ajena(t):
|
||
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
|
||
return
|
||
m = medir_braw(t["ruta"])
|
||
if not m.get("ok"):
|
||
set_trabajo(t["id"], estado="error", log=m.get("error") or "el lector de BRAW no pudo abrir el clip")
|
||
return
|
||
motor, pega = _motor_de(formato, t["chroma"] or "420", m["ancho"], m["alto"])
|
||
if not motor:
|
||
set_trabajo(t["id"], estado="error", log=pega)
|
||
return
|
||
# la tasa se calcula con el tamaño de verdad del clip, que es el que dice el decodificador
|
||
kbps = int(kbps_objetivo(m["ancho"], m["alto"], m["fps"], t["perfil"]) * fm.get("bpp_mult", 1.0))
|
||
t = dict(t, kbps=kbps)
|
||
con = db()
|
||
with con:
|
||
con.execute("UPDATE archivos SET ancho = ?, alto = ?, fps = ?, dur = ?, perfil = ? WHERE ruta = ?",
|
||
(m["ancho"], m["alto"], m["fps"], m["dur"], _curva(m), t["ruta"]))
|
||
con.execute("UPDATE trabajos SET kbps = ? WHERE id = ?", (kbps, t["id"]))
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
con.close()
|
||
os.makedirs(os.path.dirname(salida), exist_ok=True)
|
||
tmp = salida + ".parcial" + fm["ext"]
|
||
video, pix = _args_video(t, fm, motor, m["fps"])
|
||
contenedor, audio = _args_salida(fm, f)
|
||
ficha = os.path.join(DATOS, f"braw_{t['id']}.json")
|
||
bandera = ficha + ".cancelar"
|
||
guardar_json(ficha, {"src": t["ruta"], "tmp": tmp, "ffmpeg": ruta_herramienta("ffmpeg"), "pix": pix, "video": video,
|
||
"contenedor": contenedor, "audio": audio, "color": t["color"] or "rec709", "cancelar": bandera,
|
||
"sdk": dec.get("carpeta") or ""})
|
||
try:
|
||
os.remove(bandera)
|
||
except OSError:
|
||
pass
|
||
errores = tempfile.TemporaryFile()
|
||
try:
|
||
p = subprocess.Popen([sys.executable, os.path.join(BASE, "braw.py"), "convertir", ficha], stdout=subprocess.PIPE, stderr=errores,
|
||
text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
|
||
except OSError as ex:
|
||
errores.close()
|
||
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}")
|
||
return
|
||
|
||
def vigia():
|
||
# Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se corta
|
||
while p.poll() is None:
|
||
if COLA["cancelar"]:
|
||
try:
|
||
open(bandera, "w").close()
|
||
p.wait(20)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
p.kill()
|
||
return
|
||
time.sleep(0.5)
|
||
threading.Thread(target=vigia, daemon=True).start()
|
||
fin, dicho = {}, ""
|
||
for linea in p.stdout:
|
||
try:
|
||
ev = json.loads(linea)
|
||
except ValueError:
|
||
continue
|
||
if ev.get("estado"):
|
||
fin = ev
|
||
elif "pct" in ev:
|
||
set_trabajo(t["id"], pct=ev["pct"])
|
||
elif ev.get("msg"):
|
||
dicho = ev["msg"]
|
||
set_trabajo(t["id"], log=dicho)
|
||
p.wait()
|
||
p.stdout.close()
|
||
try:
|
||
errores.seek(0)
|
||
cola_err = errores.read().decode("utf-8", "replace").strip()[-2000:]
|
||
except (OSError, ValueError):
|
||
cola_err = ""
|
||
errores.close()
|
||
for resto in (ficha, bandera):
|
||
try:
|
||
os.remove(resto)
|
||
except OSError:
|
||
pass
|
||
if COLA["cancelar"] or fin.get("estado") == "cancelado":
|
||
set_trabajo(t["id"], estado="cancelado", pct=0)
|
||
_quitar_parcial(tmp)
|
||
return
|
||
if fin.get("estado") != "listo" or not os.path.exists(tmp):
|
||
set_trabajo(t["id"], estado="error",
|
||
log=(fin.get("log") or cola_err or f"el lector de BRAW se cerró sin acabar el clip (código {p.returncode})")[-3000:])
|
||
_quitar_parcial(tmp)
|
||
return
|
||
os.replace(tmp, salida)
|
||
try:
|
||
st = os.stat(t["ruta"])
|
||
os.utime(salida, (st.st_atime, st.st_mtime))
|
||
except OSError:
|
||
pass
|
||
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time(),
|
||
log=(f"{dicho} · {fin.get('segundos', 0)} s · " + " ".join(video))[:4000])
|
||
|
||
|
||
def correr_resolve(trabajos):
|
||
"""Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte)."""
|
||
o = CONFIG["optimizar"]
|
||
con = db()
|
||
jobs = []
|
||
for t in trabajos:
|
||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
jobs.append({"id": t["id"], "src": t["ruta"], "dst": t["salida"], "w": (f["ancho"] if f else 0) or 0,
|
||
"h": (f["alto"] if f else 0) or 0, "fps": (f["fps"] if f else 0) or 0, "kbps": t["kbps"],
|
||
"color": t["color"] or "rec709"})
|
||
set_trabajo(t["id"], estado="corriendo", pct=0)
|
||
con.close()
|
||
lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json")
|
||
with open(lote, "w", encoding="utf-8") as fh:
|
||
json.dump(jobs, fh, ensure_ascii=False)
|
||
# el puente corre con el mismo motor que el programa (en el paquete, su propio .exe haciendo de intérprete)
|
||
cmd = [o.get("python_resolve") or sys.executable, os.path.join(BASE, "resolve_bridge.py"), lote]
|
||
try:
|
||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
|
||
except OSError as ex:
|
||
for t in trabajos:
|
||
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el puente de Resolve: {ex}")
|
||
return
|
||
log_general = []
|
||
for linea in p.stdout:
|
||
linea = linea.strip()
|
||
if not linea:
|
||
continue
|
||
if linea.startswith("{"):
|
||
try:
|
||
ev = json.loads(linea)
|
||
except ValueError:
|
||
log_general.append(linea)
|
||
continue
|
||
if "id" in ev:
|
||
kw = {}
|
||
if "pct" in ev:
|
||
kw["pct"] = ev["pct"]
|
||
if ev.get("estado"):
|
||
kw["estado"] = ev["estado"]
|
||
if ev.get("log"):
|
||
kw["log"] = ev["log"][-3000:]
|
||
if ev.get("estado") == "listo":
|
||
kw["fin"] = time.time()
|
||
try:
|
||
kw["bytes_out"] = os.path.getsize(ev.get("salida") or "")
|
||
except OSError:
|
||
pass
|
||
set_trabajo(ev["id"], **kw)
|
||
else:
|
||
COLA["mensaje"] = ev.get("msg", "")
|
||
log_general.append(ev.get("msg", ""))
|
||
else:
|
||
log_general.append(linea)
|
||
if COLA["cancelar"]:
|
||
p.kill()
|
||
break
|
||
p.wait()
|
||
try:
|
||
os.remove(lote)
|
||
except OSError:
|
||
pass
|
||
con = db()
|
||
for t in trabajos:
|
||
r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
|
||
if r and r["estado"] == "corriendo":
|
||
set_trabajo(t["id"], estado="cancelado" if COLA["cancelar"] else "error",
|
||
log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo")
|
||
con.close()
|
||
|
||
|
||
def _verificar_listos(ids):
|
||
"""al acabar cada conversión se verifica sola (códec, 10 bits, duración): el usuario solo decide si reemplaza"""
|
||
try:
|
||
con = db()
|
||
listos = [r["id"] for r in con.execute(f"SELECT id FROM trabajos WHERE estado = 'listo' AND id IN ({','.join('?' * len(ids))})", ids).fetchall()] if ids else []
|
||
con.close()
|
||
if listos:
|
||
verificar(listos)
|
||
except Exception as ex: # noqa
|
||
COLA["mensaje"] = f"no se pudo verificar: {ex}"
|
||
|
||
|
||
def _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 bucle_cola():
|
||
try:
|
||
while COLA["activa"]:
|
||
con = db()
|
||
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY orden DESC, id LIMIT 1").fetchone()
|
||
lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()]
|
||
con.close()
|
||
if t and not _esperar_turno():
|
||
break
|
||
if t:
|
||
t = _clip_presente(dict(t))
|
||
if not t:
|
||
continue
|
||
if not t and lote_resolve:
|
||
lote_resolve = [x for x in map(_clip_presente, lote_resolve) if x]
|
||
if not lote_resolve:
|
||
continue
|
||
if t:
|
||
COLA["actual"] = t["id"]
|
||
COLA["empezo"] = time.time()
|
||
set_trabajo(t["id"], estado="corriendo", pct=0)
|
||
try:
|
||
(correr_braw if t["motor"] == "braw" else correr_ffmpeg)(dict(t))
|
||
except Exception as ex: # noqa
|
||
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
|
||
_verificar_listos([t["id"]])
|
||
_anotar_ritmo(t["id"], time.time() - COLA["empezo"])
|
||
if CONFIG["optimizar"].get("auto_reemplazar"):
|
||
try: # solo reemplaza lo verificado; lo demás lo deja como está
|
||
reemplazar([t["id"]])
|
||
except Exception as ex: # noqa
|
||
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
|
||
COLA["hechos"] += 1
|
||
COLA["hechos_ids"].append(t["id"])
|
||
elif lote_resolve:
|
||
COLA["actual"] = lote_resolve[0]["id"]
|
||
try:
|
||
correr_resolve(lote_resolve)
|
||
except Exception as ex: # noqa
|
||
for r in lote_resolve:
|
||
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
|
||
_verificar_listos([r["id"] for r in lote_resolve])
|
||
COLA["hechos"] += len(lote_resolve)
|
||
COLA["hechos_ids"].extend(r["id"] for r in lote_resolve)
|
||
else:
|
||
break
|
||
COLA["actual"] = None
|
||
if COLA["cancelar"]:
|
||
COLA["cancelar"] = False
|
||
break
|
||
except Exception as ex: # noqa
|
||
COLA["mensaje"] = f"la cola se detuvo por un error: {ex}"
|
||
finally:
|
||
# pase lo que pase, la cola no queda «activa» para siempre (bloqueaba el cierre del programa)
|
||
COLA["activa"] = False
|
||
COLA["actual"] = None
|
||
COLA["hilo"] = None
|
||
COLA["esperando"] = ""
|
||
try:
|
||
_resumen_tanda()
|
||
except Exception as ex: # noqa
|
||
COLA["mensaje"] = f"no se pudo apuntar el resumen de la tanda: {ex}"
|
||
COLA["hechos_ids"] = []
|
||
|
||
|
||
def _anotar_ritmo(id_, segundos):
|
||
"""Cuánto clip salió por segundo real en este trabajo, si acabó bien: con eso se calcula cuánto falta."""
|
||
try:
|
||
con = db()
|
||
t = con.execute("SELECT estado, ruta FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||
con.close()
|
||
if t and t["estado"] in ("listo", "verificado", "reemplazado", "descartado") and f and (f["dur"] or 0) > 0 and segundos > 0:
|
||
COLA["ritmo"].append((float(f["dur"]), float(segundos)))
|
||
except Exception: # noqa
|
||
pass
|
||
|
||
|
||
def eta_cola(trabajos):
|
||
"""Cuánto falta para que acabe la cola: segundos de clip pendientes partidos por el ritmo medido (clips ya
|
||
hechos en esta tanda; si no hay ninguno, el avance del que va; si tampoco, a tiempo real los BRAW y a 2,5×
|
||
la tarjeta). Devuelve también el clip que va, con lo que le queda."""
|
||
pend = [t for t in trabajos if t["estado"] in ("pendiente", "corriendo")]
|
||
if not pend:
|
||
return None
|
||
con = db()
|
||
durs = {}
|
||
for t in pend:
|
||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||
durs[t["id"]] = float((f["dur"] if f else 0) or 0)
|
||
con.close()
|
||
ritmo = COLA.get("ritmo") or []
|
||
clip_s, real_s = sum(d for d, _ in ritmo), sum(s for _, s in ritmo)
|
||
vel, medido = (clip_s / real_s if clip_s > 0 and real_s > 0 else 0.0), "tanda" if ritmo else ""
|
||
falta, actual, ahora = 0.0, None, time.time()
|
||
for t in pend:
|
||
d = durs[t["id"]]
|
||
if t["estado"] == "corriendo":
|
||
pct = float(t["pct"] or 0)
|
||
resto = d * max(0.0, 1 - pct / 100)
|
||
falta += resto
|
||
llevado = ahora - (COLA.get("empezo") or ahora)
|
||
if not vel and pct >= 3 and llevado > 5:
|
||
vel, medido = (d * pct / 100) / llevado, "clip"
|
||
actual = {"id": t["id"], "dur": d, "resto": resto, "llevado": llevado}
|
||
else:
|
||
falta += d
|
||
if not vel:
|
||
vel = 1.0 if any(t["motor"] == "braw" for t in pend) else 2.5
|
||
seg = falta / vel if falta else 0.0
|
||
if actual:
|
||
actual["resto_seg"] = actual["resto"] / vel
|
||
return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel, 2), "medido": medido, "termina": ahora + seg if seg else 0,
|
||
"actual": actual, "en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)}
|
||
|
||
|
||
def _ruta_original(t):
|
||
"""Dónde está hoy el original de un trabajo: en su sitio o, si ya se reemplazó, en _Para borrar/originales."""
|
||
if os.path.isfile(t["ruta"]):
|
||
return t["ruta"]
|
||
try:
|
||
otra = os.path.join(t["raiz"], R.PAPELERA, "originales", os.path.relpath(t["ruta"], t["raiz"]))
|
||
except ValueError:
|
||
return ""
|
||
return otra if os.path.isfile(otra) else ""
|
||
|
||
|
||
def cuadro_de(id_, cual, segundo, ancho=1600):
|
||
"""Un cuadro (JPEG) del original o del convertido de un trabajo, en el segundo pedido; se guarda en
|
||
datos/cuadros para no sacarlo dos veces. Devuelve la ruta del JPEG o lanza ValueError."""
|
||
con = db()
|
||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||
con.close()
|
||
if not t:
|
||
raise ValueError("no existe ese trabajo")
|
||
segundo = max(0.0, float(segundo or 0))
|
||
carpeta = os.path.join(DATOS, "cuadros")
|
||
os.makedirs(carpeta, exist_ok=True)
|
||
salida = os.path.join(carpeta, f"{id_}_{'o' if cual == 'original' else 'c'}_{int(round(segundo * 10))}.jpg")
|
||
if os.path.isfile(salida) and os.path.getsize(salida) > 0:
|
||
return salida
|
||
if cual == "original":
|
||
src = _ruta_original(dict(t))
|
||
if not src:
|
||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||
else:
|
||
src = t["salida"]
|
||
if not os.path.isfile(src):
|
||
raise ValueError("el convertido ya no está donde se hizo")
|
||
if cual == "original" and t["motor"] == "braw":
|
||
dec = decodificador_braw()
|
||
if not dec.get("ok"):
|
||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||
ficha = os.path.join(DATOS, f"cuadro_{id_}.json")
|
||
guardar_json(ficha, {"src": src, "segundo": segundo, "salida": salida, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho})
|
||
try:
|
||
ev = [e for e in _lector_braw("cuadro", ficha, espera=180) if e.get("estado")]
|
||
finally:
|
||
try:
|
||
os.remove(ficha)
|
||
except OSError:
|
||
pass
|
||
if not ev or ev[-1].get("estado") != "listo":
|
||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo sacar el cuadro")
|
||
else:
|
||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{segundo:.3f}", "-i", src,
|
||
"-frames:v", "1", "-vf", f"scale={ancho}:-2:flags=bicubic,format=yuvj420p", "-q:v", "2", "-f", "image2", salida]
|
||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, creationflags=SIN_VENTANA)
|
||
if r.returncode != 0 or not os.path.isfile(salida):
|
||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo sacar el cuadro")
|
||
return salida
|
||
|
||
|
||
def calidad_de(id_, segundos=10, ancho=1920):
|
||
"""Nota de calidad (VMAF, 0 a 100) del convertido de un trabajo contra su original, sobre un tramo del medio
|
||
del clip. Se guarda en el trabajo (nota, nota_min). Lanza ValueError si no se puede."""
|
||
con = db()
|
||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||
con.close()
|
||
if not t:
|
||
raise ValueError("no existe ese trabajo")
|
||
src = _ruta_original(dict(t))
|
||
if not src:
|
||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||
if not os.path.isfile(t["salida"]):
|
||
raise ValueError("el convertido ya no está donde se hizo")
|
||
fps = float((f["fps"] if f else 0) or 0) or 25.0
|
||
dur = float((f["dur"] if f else 0) or 0)
|
||
total = int(dur * fps) if dur else 0
|
||
n = max(24, min(int(segundos * fps), 300, total or 300))
|
||
desde = max(0, (total - n) // 2) if total else 0
|
||
hilos = max(2, min(16, (os.cpu_count() or 8) - 2))
|
||
carpeta = os.path.join(DATOS, "cuadros")
|
||
os.makedirs(carpeta, exist_ok=True)
|
||
log = f"calidad_{id_}.json"
|
||
t0 = time.time()
|
||
if t["motor"] == "braw":
|
||
dec = decodificador_braw()
|
||
if not dec.get("ok"):
|
||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||
ficha = os.path.join(DATOS, f"calidad_{id_}.ficha.json")
|
||
guardar_json(ficha, {"src": src, "conv": t["salida"], "desde": desde, "cuadros": n, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho, "log": log, "cwd": carpeta, "vmaf_hilos": hilos})
|
||
try:
|
||
ev = [e for e in _lector_braw("calidad", ficha, espera=900) if e.get("estado")]
|
||
finally:
|
||
try:
|
||
os.remove(ficha)
|
||
except OSError:
|
||
pass
|
||
if not ev or ev[-1].get("estado") != "listo":
|
||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo medir")
|
||
nota, minimo, cuadros = ev[-1]["vmaf"], ev[-1]["minimo"], ev[-1]["medidos"]
|
||
else:
|
||
fc = (f"[0:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r];"
|
||
f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d];"
|
||
f"[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={log}")
|
||
ss = f"{desde / fps:.6f}"
|
||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", ss, "-i", src, "-ss", ss, "-i", t["salida"],
|
||
"-filter_complex", fc, "-frames:v", str(n), "-an", "-f", "null", "-"]
|
||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, cwd=carpeta, creationflags=SIN_VENTANA)
|
||
ruta_log = os.path.join(carpeta, log)
|
||
if r.returncode != 0 or not os.path.isfile(ruta_log):
|
||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo medir (¿este ffmpeg trae libvmaf?)")
|
||
with open(ruta_log, "r", encoding="utf-8") as fh:
|
||
j = json.load(fh)
|
||
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
|
||
nota, minimo, cuadros = round(float(v.get("mean") or 0), 1), round(float(v.get("min") or 0), 1), len(j.get("frames") or [])
|
||
try:
|
||
os.remove(os.path.join(carpeta, log))
|
||
except OSError:
|
||
pass
|
||
set_trabajo(id_, nota=nota, nota_min=minimo)
|
||
return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)}
|
||
|
||
|
||
def recuperar_huerfanas():
|
||
"""Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de
|
||
una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso
|
||
Convertir decía «nada» (visto el 2026-09-15: 5 clips de V:\\Galeano al 18-89 % desde el día 10).
|
||
Vuelven a «pendiente» con el motivo en el log; Reanudar los convierte desde cero."""
|
||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||
return 0
|
||
con = db()
|
||
with con:
|
||
filas = con.execute("SELECT id, pct FROM trabajos WHERE estado = 'corriendo'").fetchall()
|
||
for f in filas:
|
||
con.execute("UPDATE trabajos SET estado = 'pendiente', pct = 0, log = ? WHERE id = ?",
|
||
(f"se interrumpió al cerrar OrganiLAB (iba al {round(f['pct'] or 0)} %); pulsa Reanudar para convertirlo de nuevo", f["id"]))
|
||
con.close()
|
||
return len(filas)
|
||
|
||
|
||
def braws_al_lector():
|
||
"""Las conversiones de BRAW que quedaron en espera o fallidas por la vía de Resolve pasan al lector
|
||
directo. De cada clip se queda una sola, la última; las que apuntan a un clip que ya no está en esa ruta
|
||
—la carpeta se reordenó después— se quitan, porque reintentarlas no podía salir bien."""
|
||
if not decodificador_braw().get("ok"):
|
||
return 0
|
||
con = db()
|
||
n = 0
|
||
with con:
|
||
vistos = set()
|
||
for t in con.execute("SELECT id, ruta, raiz, formato FROM trabajos WHERE motor = 'resolve' "
|
||
"AND estado IN ('pendiente','error','cancelado') ORDER BY id DESC").fetchall():
|
||
if not os.path.isdir(t["raiz"] or ""):
|
||
continue # unidad desconectada: no se sabe si el clip sigue ahí
|
||
clave = (os.path.normcase(t["ruta"]), t["formato"] or "h265_10bit")
|
||
if clave in vistos or not os.path.isfile(t["ruta"]):
|
||
con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],))
|
||
else:
|
||
vistos.add(clave)
|
||
con.execute("UPDATE trabajos SET motor = 'braw' WHERE id = ?", (t["id"],))
|
||
con.execute("UPDATE trabajos SET log = ? WHERE id = ? AND estado = 'error'",
|
||
("no salió por la vía de Resolve; ahora se lee sin Resolve: pulsa «Reintentar los que fallaron»", t["id"]))
|
||
n += 1
|
||
con.close()
|
||
return n
|
||
|
||
|
||
class ErrorCola(ValueError):
|
||
"""Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto."""
|
||
def __init__(self, mensaje, datos):
|
||
super().__init__(mensaje)
|
||
self.datos = datos
|
||
|
||
|
||
_COLA_LOCK = threading.Lock()
|
||
|
||
|
||
def iniciar_cola():
|
||
# el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que
|
||
# alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal.
|
||
with _COLA_LOCK:
|
||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||
return False
|
||
recuperar_huerfanas()
|
||
try:
|
||
braws_al_lector()
|
||
except Exception: # noqa
|
||
pass
|
||
COLA["activa"] = True
|
||
COLA["cancelar"] = False
|
||
COLA["hechos"] = 0
|
||
COLA["inicio"] = time.time()
|
||
COLA["ritmo"] = []
|
||
COLA["hechos_ids"] = []
|
||
COLA["esperando"] = ""
|
||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||
COLA["hilo"].start()
|
||
return True
|
||
|
||
|
||
def _no_compensa(t, sale=None):
|
||
"""Por qué no merece la pena dar por buena esta conversión, o «» si sí. Solo cuenta para los formatos que
|
||
existen para pesar menos (H.265). El 5-oct-2026 había 8 conversiones verificadas esperando en la cola de
|
||
V:\\Galeano con el botón «Reemplazar 8 verificados»: seis eran reconversiones de un clip ya convertido
|
||
y pesaban entre el 85 y el 99 % de él. Reemplazar habría cambiado cada uno por una copia de su copia."""
|
||
if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc":
|
||
return ""
|
||
entra, sale = t["bytes_in"] or 0, sale or t["bytes_out"] or 0
|
||
if not entra or not sale:
|
||
return ""
|
||
repetida = R.es_conversion(os.path.basename(t["ruta"]))
|
||
if not repetida and sale <= (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()}
|
||
actual = None
|
||
if COLA["actual"]:
|
||
t = con.execute("SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone()
|
||
if t:
|
||
actual = dict(t)
|
||
con.close()
|
||
# «clip 3 de 12»: los hechos en esta tanda + el que va + los que esperan
|
||
total_tanda = COLA["hechos"] + (1 if actual else 0) + cola_n.get("pendiente", 0)
|
||
esc_ci = {k.lower(): v for k, v in esc.items()}
|
||
raices = []
|
||
for r in CONFIG["raices"]:
|
||
k = _clave(r["ruta"])
|
||
raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)})
|
||
return {"config": CONFIG, "raices": raices, "herramientas": herramientas(),
|
||
"version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO,
|
||
"cola": {"activa": COLA["activa"], "actual": actual, "mensaje": COLA["mensaje"], "n": cola_n,
|
||
"hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]},
|
||
"aplicando": APLICANDO, "asistente": asistente.ESTADO,
|
||
"respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])},
|
||
"traer": {k: v for k, v in TRAER.items() if k != "errores"} | {"errores_n": len(TRAER.get("errores") or [])},
|
||
"automatico": dict(AUTO),
|
||
"cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "formatos": {k: {"nombre": v["nombre"], "que": v["que"], "ext": v["ext"]} for k, v in FORMATOS.items()}, "plantillas": PLANTILLAS}
|
||
|
||
|
||
def _plural(n, uno, varios):
|
||
return f"{n:,}".replace(",", ".") + " " + (uno if n == 1 else varios)
|
||
|
||
|
||
# Qué enseñar en lugar de cada variable de una plantilla, y cómo se llama en llano.
|
||
EJEMPLO_VAR = {
|
||
"anio": ("2026", "el año del material"),
|
||
"mes": ("09- Septiembre", "el mes del material"),
|
||
"dia": ("15- Viernes", "el día en que se grabó"),
|
||
"nombre": ("Nombre de la pieza", "el nombre de la pieza o de la carpeta"),
|
||
"fuente": ("URSA_BRAW", "la cámara de la que salió"),
|
||
"app": ("Premiere", "el programa que abre el proyecto"),
|
||
}
|
||
# Estas dos casi siempre están vacías (el archivo cae directo en la carpeta de arriba), así que no son
|
||
# un nivel más del árbol: son una nota en el nivel que las contiene.
|
||
VAR_NOTA = {
|
||
"sub": "y si el material venía dentro de una subcarpeta, se conserva",
|
||
"rel": "se copia la ruta tal cual venía, para poder devolverla a su sitio",
|
||
}
|
||
|
||
|
||
def mapa_estructura(tipo, raiz=None):
|
||
"""El árbol de carpetas que produce una plantilla, con qué cae en cada una y cuánto hay ya.
|
||
|
||
Es la respuesta a «¿dónde va a acabar mi material?» antes de mover nada. Se arma con las plantillas
|
||
de verdad, así que lo que enseña es exactamente lo que la app va a hacer, y si se edita una
|
||
plantilla el árbol cambia con ella."""
|
||
tipo = tipo if tipo in PLANTILLAS else "contenido"
|
||
pl = PLANTILLAS[tipo]
|
||
|
||
# qué hay hoy en la carpeta, repartido por cubeta según el rol de cada archivo
|
||
hay, apps, cliente = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0]), ""
|
||
if raiz:
|
||
raiz = _clave(raiz)
|
||
r = raiz_cfg(raiz)
|
||
cliente = r["cliente"] if r else ""
|
||
con = db()
|
||
for f in con.execute("SELECT rol, ext, COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos "
|
||
"WHERE raiz = ? GROUP BY rol, ext", (raiz,)).fetchall():
|
||
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
|
||
hay[cub][0] += f["n"]
|
||
hay[cub][1] += f["b"]
|
||
if cub == "PROYECTOS":
|
||
a = R.app_proyecto(f["ext"]) or "Sin programa reconocido"
|
||
apps[a][0] += f["n"]
|
||
apps[a][1] += f["b"]
|
||
con.close()
|
||
|
||
# nombres de archivo de verdad, para poder probar el mapa con lo que hay en el disco
|
||
ejemplos = defaultdict(list)
|
||
if raiz:
|
||
con = db()
|
||
for f in con.execute("SELECT rol, nombre, MAX(bytes) b FROM archivos WHERE raiz = ? AND es_basura = 0 "
|
||
"AND es_proxy = 0 GROUP BY rol, ext ORDER BY b DESC", (raiz,)).fetchall():
|
||
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
|
||
if len(ejemplos[cub]) < 4:
|
||
ejemplos[cub].append(f["nombre"])
|
||
con.close()
|
||
|
||
# cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta
|
||
roles = defaultdict(list)
|
||
for rol, cub in R.ROL_A_CUBETA.items():
|
||
roles[cub].append(R.ROL_QUE.get(rol, rol))
|
||
|
||
raiz_nodo = {"nombre": os.path.basename(raiz.rstrip(os.sep)) or raiz if raiz else "Carpeta del cliente",
|
||
"hijos": [], "cubetas": [], "variable": "", "nota": "", "crudo": [], "color": "",
|
||
"n": 0, "bytes": 0}
|
||
|
||
def bajar(nodo, nombre, variable, crudo):
|
||
for h in nodo["hijos"]:
|
||
if h["nombre"].lower() == nombre.lower():
|
||
return h
|
||
h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "",
|
||
"crudo": list(crudo), "color": "", "n": 0, "bytes": 0}
|
||
nodo["hijos"].append(h)
|
||
return h
|
||
|
||
cubetas = []
|
||
for cub, titulo in R.CUBETAS.items():
|
||
plantilla = pl.get(cub, "")
|
||
n, b = hay.get(cub, [0, 0])
|
||
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
|
||
"asignable": cub not in ("CONTENEDOR", "IGNORAR", "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»
|
||
filas = [dict(r) for r in con.execute("SELECT t.*, a.dur AS dur, a.fps AS fps FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||
"ORDER BY t.id DESC LIMIT 500").fetchall()]
|
||
for f in filas: # un original ya reemplazado vive en _Para borrar/originales: su ficha está ahí
|
||
if f.get("dur") is None and f["estado"] == "reemplazado":
|
||
otra = _ruta_original(f)
|
||
a = con.execute("SELECT dur, fps FROM archivos WHERE ruta = ?", (otra,)).fetchone() if otra else None
|
||
if a:
|
||
f["dur"], f["fps"] = a["dur"], a["fps"]
|
||
con.close()
|
||
try:
|
||
eta = eta_cola(filas) if COLA["activa"] else None
|
||
except Exception: # noqa
|
||
eta = None
|
||
if COLA.get("ultima") is None:
|
||
COLA["ultima"] = _ultima_tanda_guardada()
|
||
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta,
|
||
"esperando": COLA.get("esperando") or "", "ultima": COLA.get("ultima") or None}})
|
||
if ruta == "/api/optimizar/tanda.csv": # la tanda, en CSV
|
||
csv_ = tanda_csv(q.get("lote") or "")
|
||
if csv_ is None:
|
||
return self._json({"error": "no hay una tanda con ese nombre"}, 404)
|
||
cuerpo = csv_.encode("utf-8-sig")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
||
self.send_header("Content-Disposition", f"attachment; filename=\"OrganiLAB-{q.get('lote') or 'tanda'}.csv\"")
|
||
self.send_header("Content-Length", str(len(cuerpo)))
|
||
self.end_headers()
|
||
self.wfile.write(cuerpo)
|
||
return
|
||
if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar»
|
||
try:
|
||
jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0))
|
||
except (ValueError, TypeError) as ex:
|
||
return self._json({"error": str(ex)}, 400)
|
||
with open(jpg, "rb") as fh:
|
||
cuerpo = fh.read()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "image/jpeg")
|
||
self.send_header("Content-Length", str(len(cuerpo)))
|
||
self.send_header("Cache-Control", "private, max-age=86400")
|
||
self.end_headers()
|
||
self.wfile.write(cuerpo)
|
||
return
|
||
if ruta == "/api/stock":
|
||
tipos = [t for t in (q.get("tipos") or "Video").split(",") if t]
|
||
return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
|
||
if ruta == "/api/papelera":
|
||
return self._json(papelera(os.path.normpath(q["raiz"])))
|
||
if ruta == "/api/vacias":
|
||
return self._json(carpetas_vacias(os.path.normpath(q["raiz"])))
|
||
if ruta == "/api/estructura/archivo":
|
||
return self._json(donde_iria(q.get("nombre", ""), q.get("tipo") or "contenido"))
|
||
if ruta == "/api/estructura/existen":
|
||
try:
|
||
segs = json.loads(q.get("ruta") or "[]")
|
||
except ValueError:
|
||
segs = []
|
||
cs = carpetas_como(os.path.normpath(q["raiz"]), segs) if segs else []
|
||
base = os.path.normpath(q["raiz"])
|
||
return self._json({"n": len(cs), "carpetas": [os.path.relpath(c, base) for c in cs[:20]]})
|
||
if ruta == "/api/estructura":
|
||
raiz = q.get("raiz")
|
||
tipo = q.get("tipo")
|
||
if raiz and not tipo:
|
||
r = raiz_cfg(os.path.normpath(raiz))
|
||
tipo = r.get("tipo") if r else None
|
||
return self._json(mapa_estructura(tipo or "contenido", os.path.normpath(raiz) if raiz else None))
|
||
if ruta == "/api/duplicados":
|
||
return self._json(duplicados(os.path.normpath(q["raiz"]), calidades=q.get("calidades") == "1"))
|
||
if ruta == "/api/respaldo/plan":
|
||
raiz = os.path.normpath(q["raiz"])
|
||
destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "")
|
||
if not destino:
|
||
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
|
||
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0))
|
||
PLANES_RESPALDO[raiz] = plan
|
||
# calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
|
||
# reescribía config.json)
|
||
return self._json({k: v for k, v in plan.items() if k != "items"})
|
||
if ruta == "/api/respaldo/estado":
|
||
return self._json(RESPALDO)
|
||
if ruta == "/api/buscar-carpeta":
|
||
return self._json({"coincidencias": buscar_carpeta(q.get("nombre", ""))})
|
||
if ruta == "/api/entrada":
|
||
return self._json(entrada(q["raiz"]))
|
||
if ruta == "/api/automatico":
|
||
return self._json(estado_automatico())
|
||
if ruta == "/api/pieza/previa":
|
||
return self._json(nueva_pieza(q["raiz"], q.get("titulo", ""), q.get("anio"), q.get("mes"), crear=False))
|
||
return self._json({"error": "no existe"}, 404)
|
||
except Exception as ex: # noqa
|
||
import traceback
|
||
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
|
||
|
||
def do_POST(self):
|
||
ULTIMA_PETICION[0] = time.time()
|
||
u = urlparse(self.path)
|
||
ruta = u.path
|
||
if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"):
|
||
return self._json({"error": "petición no permitida"}, 403)
|
||
d = self._cuerpo()
|
||
try:
|
||
if ruta == "/api/escanear":
|
||
return self._json({"lanzado": lanzar_escaneo(d["raiz"])})
|
||
if ruta == "/api/elegir-carpeta":
|
||
try:
|
||
que = "Elige la tarjeta o la carpeta con el material nuevo" if d.get("para") == "traer" else "Elige la carpeta del cliente"
|
||
return self._json({"ruta": elegir_carpeta(que)})
|
||
except ValueError as e:
|
||
return self._json({"error": str(e)}, 409)
|
||
if ruta == "/api/raiz/quitar":
|
||
try:
|
||
return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")), bool(d.get("descartar_cola"))))
|
||
except ErrorCola as e:
|
||
return self._json({"error": str(e), **e.datos}, 409)
|
||
except ValueError as e:
|
||
return self._json({"error": str(e)}, 409)
|
||
if ruta == "/api/raices":
|
||
choque = _anidadas(d.get("raices") or [])
|
||
if choque:
|
||
return self._json({"error": f"«{choque[1]}» está dentro de «{choque[0]}», que ya está en la lista. "
|
||
"Dos carpetas anidadas se pisan el inventario: deja solo una de las dos."}, 400)
|
||
CONFIG["raices"] = d["raices"]
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
return self._json({"ok": True})
|
||
if ruta == "/api/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", "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]
|
||
t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None
|
||
con.close()
|
||
except sqlite3.Error:
|
||
return -1
|
||
total = COLA["hechos"] + (1 if t else 0) + espera
|
||
return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1
|
||
if RESPALDO.get("estado") == "corriendo":
|
||
return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1
|
||
if TRAER.get("estado") == "copiando":
|
||
return TRAER["bytes"] / TRAER["bytes_total"] if TRAER.get("bytes_total") else -1
|
||
if AUTO.get("activo"):
|
||
return AUTO["hechas"] / AUTO["total"] if AUTO.get("total") else -1
|
||
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
|
||
return APLICANDO["unidades"] / APLICANDO["unidades_total"] if APLICANDO.get("unidades_total") else APLICANDO["hechas"] / APLICANDO["total"]
|
||
if asistente.ESTADO.get("activo"):
|
||
return asistente.ESTADO["pct"] / 100 if asistente.ESTADO.get("total") else -1
|
||
for a in list(ANALISIS.values()):
|
||
if (a or {}).get("estado") == "corriendo":
|
||
return a["hechos"] / a["total"] if a.get("total") else -1
|
||
if any((p or {}).get("estado") == "corriendo" for p in list(PROGRESO.values())):
|
||
return -1
|
||
if actualizador.ESTADO.get("estado") == "descargando":
|
||
return (actualizador.ESTADO.get("pct") or 0) / 100
|
||
return None
|
||
|
||
|
||
def _marca():
|
||
"""Un número que identifica esta instalación por su carpeta de datos."""
|
||
return (zlib.crc32(os.path.normcase(os.path.abspath(DATOS)).encode("utf-8")) & 0x7FFFFFFF) or 1
|
||
|
||
|
||
def _soy_la_unica():
|
||
"""False si ya hay otra copia del programa abierta sobre estos mismos datos: dos a la vez se pisarían el
|
||
inventario, y la segunda daría por huérfanas las conversiones que la primera tiene en marcha."""
|
||
if os.name != "nt":
|
||
return True
|
||
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
||
k.CreateMutexW.argtypes, k.CreateMutexW.restype = [ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p], ctypes.c_void_p
|
||
h = k.CreateMutexW(None, False, f"OrganiLAB-{_marca():08x}")
|
||
if h and ctypes.get_last_error() == 183: # ya existía
|
||
return False
|
||
_CANDADO.append(h)
|
||
return True
|
||
|
||
|
||
def _limpiar_restos():
|
||
"""Lo que dejó el .exe de PyInstaller de las versiones hasta la 2.x: su carpeta _internal, que ya no usa
|
||
nadie (el motor de ahora va en la carpeta runtime)."""
|
||
viejo = os.path.join(BASE, "_internal")
|
||
if CONGELADO and not getattr(sys, "frozen", False) and os.path.isfile(os.path.join(viejo, "base_library.zip")):
|
||
shutil.rmtree(viejo, ignore_errors=True)
|
||
|
||
|
||
def _vigilar(v, fin):
|
||
"""Mientras la ventana existe: lleva el avance al botón de la barra de tareas, avisa cuando termina algo
|
||
largo y, si la ventana se cerró con trabajo pendiente, cierra el programa en cuanto acaba."""
|
||
antes, desde = None, 0.0
|
||
while not fin.wait(1.0):
|
||
try:
|
||
f = avance()
|
||
except Exception: # noqa
|
||
f = None
|
||
if f is None:
|
||
if antes is not None:
|
||
v.progreso(None)
|
||
if time.time() - desde > 20 and not v.oculta:
|
||
v.avisar()
|
||
if v.oculta and not en_marcha():
|
||
v.cerrar()
|
||
else:
|
||
if antes is None:
|
||
desde = time.time()
|
||
if not v.oculta:
|
||
v.progreso(f)
|
||
antes = f
|
||
|
||
|
||
def _recien_abierta(v):
|
||
"""Con la interfaz ya a la vista: si el programa está dado de alta en Windows, pone al día su versión y
|
||
sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece instalarlo: copiarlo a
|
||
su sitio definitivo, darlo de alta y seguir desde allí."""
|
||
try:
|
||
import ventana
|
||
import instalar
|
||
if instalar.al_dia(BASE, VERSION):
|
||
return
|
||
oferta = CONFIG.get("instalacion") or {}
|
||
if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE):
|
||
return
|
||
if instalar.instalado() and not instalar.instalado_aqui(BASE):
|
||
return # ya hay otro instalado en este equipo: esta copia va por libre
|
||
CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez
|
||
guardar_json(CONFIG_PATH, CONFIG)
|
||
time.sleep(1.5)
|
||
if not instalar.hay_que_mudar(BASE): # ya está en su sitio: solo falta darlo de alta
|
||
if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n"
|
||
"Se quita desde Configuración › Aplicaciones.", ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES:
|
||
instalar.instalar(BASE, VERSION)
|
||
v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.")
|
||
return
|
||
if v.mensaje("¿Instalar OrganiLAB en este equipo?\n\nSe copia a tu carpeta de programas, queda en el menú Inicio y en el "
|
||
"Escritorio, y se quita desde Configuración › Aplicaciones. La carpeta desde la que lo has abierto ya no hará falta.",
|
||
ventana.MB_YESNO | ventana.MB_ICONQUESTION) != ventana.IDYES:
|
||
return
|
||
v.evaluar("modal(" + json.dumps("Instalando OrganiLAB…") + ", " + json.dumps(
|
||
'<p><span class="spin"></span> Copiando el programa a su sitio definitivo. Tarda unos segundos; al terminar se abre solo desde allí.</p>') + ", null)")
|
||
try:
|
||
r = instalar.poner(BASE, VERSION, avance=v.progreso)
|
||
except Exception as ex: # noqa
|
||
v.progreso(None)
|
||
v.evaluar("document.querySelector('#modal').classList.remove('on')")
|
||
v.mensaje(f"No se pudo instalar: {ex}\n\nEl programa sigue funcionando desde esta carpeta.", ventana.MB_OK | ventana.MB_ICONWARNING)
|
||
return
|
||
v.progreso(None)
|
||
v.mensaje("OrganiLAB quedó instalado.\n\nAhora se cierra y se abre desde su sitio. La carpeta desde la que lo abriste "
|
||
"ya no hace falta: puedes borrarla.")
|
||
RELEVO.append(os.path.join(r["base"], "OrganizadorLabstream.exe"))
|
||
v.cerrar()
|
||
except Exception as ex: # noqa
|
||
print("no se pudo dar de alta en Windows:", ex)
|
||
|
||
|
||
def _ventana(url):
|
||
"""Abre la ventana del programa y no vuelve hasta que se cierra. Devuelve False si este Windows no puede
|
||
abrirla (le falta WebView2): quien llama abre entonces la interfaz en el navegador."""
|
||
global VENTANA
|
||
try:
|
||
import ventana
|
||
import instalar
|
||
ok, detalle = ventana.disponible(BASE)
|
||
if not ok:
|
||
print("Sin ventana propia:", detalle)
|
||
return False
|
||
v = ventana.Ventana(url, titulo="OrganiLAB", icono=os.path.join(BASE, "static", "organilab.ico"),
|
||
perfil=os.path.join(DATOS, "ventana"), memoria=os.path.join(DATOS, "ventana.json"),
|
||
base=BASE, marca=_marca(), relanzar=instalar.orden(BASE),
|
||
depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR")))
|
||
v.al_cerrar, v.al_soltar = en_marcha, _al_soltar
|
||
v.al_abrir = lambda: _recien_abierta(v)
|
||
fin = threading.Event()
|
||
threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start()
|
||
VENTANA = v
|
||
try:
|
||
abierta = v.mostrar()
|
||
finally:
|
||
VENTANA = None
|
||
fin.set()
|
||
if not abierta:
|
||
print("La ventana no llegó a abrirse:", v.fallo)
|
||
return abierta
|
||
except Exception as ex: # noqa
|
||
print("Sin ventana propia:", ex)
|
||
return False
|
||
|
||
|
||
def _atender_navegador():
|
||
"""Sin ventana no hay nada que cerrar: el programa sigue mientras la página del navegador pregunte (lo hace
|
||
cada pocos segundos) y se va cuando lleva tres minutos callada y no queda trabajo en marcha."""
|
||
ULTIMA_PETICION[0] = time.time()
|
||
try:
|
||
while time.time() - ULTIMA_PETICION[0] < SILENCIO_NAVEGADOR or en_marcha():
|
||
time.sleep(2)
|
||
except KeyboardInterrupt:
|
||
pass
|
||
|
||
|
||
def main():
|
||
global PUERTO
|
||
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||
if CONGELADO and "--puente" not in sys.argv:
|
||
import instalar
|
||
instalada = instalar.mudado(BASE)
|
||
if instalada: # desde esta carpeta ya se instaló el programa: se abre el instalado
|
||
if sys.argv[1:]:
|
||
sys.exit(subprocess.call([instalada] + sys.argv[1:]))
|
||
subprocess.Popen([instalada], cwd=os.path.dirname(instalada), close_fds=True)
|
||
return
|
||
if args_tmp and args_tmp[0].lower() in ("instalar", "desinstalar") and "--puente" not in sys.argv:
|
||
import instalar # hablan con cuadros de mensaje: no hace falta consola
|
||
if args_tmp[0].lower() == "instalar" and instalar.hay_que_mudar(BASE) and not _soy_la_unica():
|
||
import ventana # la mudanza se lleva los datos: con el programa abierto se quedarían a medias
|
||
ctypes.windll.user32.MessageBoxW(None, "Cierra OrganiLAB antes de instalarlo.", "OrganiLAB", ventana.MB_OK | ventana.MB_ICONINFORMATION)
|
||
sys.exit(1)
|
||
sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv))
|
||
if args_tmp and args_tmp[0].lower() == "automatico" and "--puente" not in sys.argv:
|
||
sys.exit(automatico_por_comando()) # lo lanza la tarea programada: sin consola ni ventana
|
||
if args_tmp and "--puente" not in sys.argv:
|
||
_asegurar_consola()
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception: # noqa
|
||
pass
|
||
if "--puente" in sys.argv: # puente con Resolve, como lo llamaban los .exe hasta la 2.x
|
||
import resolve_bridge
|
||
sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1]))
|
||
args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")]
|
||
if args:
|
||
if (CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True
|
||
and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?")):
|
||
actualizador.comprobar_en_segundo_plano()
|
||
codigo = cli(args)
|
||
d = actualizador.ESTADO.get("disponible")
|
||
if d:
|
||
print(f"\nHay una versión nueva del Organizador ({d['version']}). Para instalarla: OrganizadorLabstream.exe actualizar")
|
||
if CONGELADO and "--si" not in args and sys.stdin and sys.stdin.isatty():
|
||
try:
|
||
input("\nPulsa Enter para cerrar.")
|
||
except EOFError:
|
||
pass
|
||
sys.exit(codigo)
|
||
sin_ventana = "--sin-navegador" in sys.argv or "--navegador" in sys.argv
|
||
if not _soy_la_unica():
|
||
import ventana
|
||
if not sin_ventana and ventana.traer_al_frente(_marca()):
|
||
return # ya estaba abierto: se enseña esa ventana y nada más
|
||
otra = cargar_json(SESION_PATH, {}).get("puerto")
|
||
print("Ya hay un OrganiLAB abierto con estos datos" + (f", en el puerto {otra}." if otra else "."))
|
||
if otra and "--sin-navegador" not in sys.argv:
|
||
webbrowser.open(f"http://127.0.0.1:{otra}/") # esa copia no tiene ventana: va en el navegador
|
||
return
|
||
h = herramientas()
|
||
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
|
||
print("BRAW:", "se leen con el decodificador de Blackmagic" if h["braw"]["ok"]
|
||
else "falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve; solo hace falta para BRAW)")
|
||
for resto in os.listdir(DATOS): # fichas de trabajo que dejó una conversión cortada (o el guion de Resolve, que no las borraba)
|
||
if resto.endswith((".json", ".cancelar")) and resto.startswith(("resolve_lote_", "braw_")):
|
||
try:
|
||
os.remove(os.path.join(DATOS, resto))
|
||
except OSError:
|
||
pass
|
||
huerfanas = recuperar_huerfanas()
|
||
threading.Thread(target=lambda: (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()
|