diff --git a/README.md b/README.md index aaa4dc2..df8e013 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,22 @@ acciones trae `automatico` (las que se pueden hacer solas) y el progreso del mot ordenar dejan de contar como pendientes mientras no haya un plan calculado: después de ordenarlo todo la lista seguía diciendo que faltaban dos cosas que en realidad ya no tenían nada. +2.2.0, la fecha de verdad manda y ya no hay que decidir nada a mano: +- **La fecha sale de lo más antiguo que sabe el disco** (`R.fecha_real`): al copiar en Windows la fecha de + creación pasa a ser la de la copia y la de modificación se conserva; al renderizar, las dos son nuevas. + Quedarse con la más antigua de las dos da siempre la mejor estimación de cuándo se grabó. +- **Un render hereda la fecha del original** (`R.base_derivado`, `_heredar_fechas`). «C0001_10bit.mp4» se + archiva en el mes en que se grabó «C0001.MP4», no en el mes en que se convirtió. Reconoce los sufijos + habituales (`_10bit`, `_ligero`, `_h264`, `_proxy`, `_LT`, `_HQ`, `_render`, `_export`, `_master`, `_final`) + y busca el original en la misma carpeta o, si el nombre es bastante propio y único, en toda la carpeta del + cliente. Si hay dos candidatos con el mismo nombre no hereda nada. Las conversiones que hace la propia app + ya conservaban la fecha del original, tanto por ffmpeg como por Resolve. +- **Cuando la carpeta y los archivos no coinciden, manda el archivo.** El nombre del clip lo pone la cámara y + la fecha la pone el disco; el nombre de la carpeta lo escribió una persona con prisa. Así el material se + archiva en el mes en que se grabó sin preguntarle nada a nadie, y el aviso ya no pide una decisión: dice lo + que la app hizo y por qué. En el material real, los 727 GB de las dos carpetas que mentían se resuelven + solos: lo de «02-marzo» va a diciembre de 2025 y lo de «08-Agosto» a abril de 2026. + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/manifiesto.json b/manifiesto.json index 5f02fd5..3fc6910 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "2.1.0", - "builtAt": "2026-09-17T02:51:40.000Z", - "archivo": "OrganizadorLabstream-portatil-2.1.0.zip", - "size": 198698005, - "sha256": "1ba2cd2dde181b42c6de5cc0860fa52d6d201c874bb1b28285200f984c240bf4", + "version": "2.2.0", + "builtAt": "2026-09-17T02:58:48.000Z", + "archivo": "OrganizadorLabstream-portatil-2.2.0.zip", + "size": 198699833, + "sha256": "89d8437d47c05d9fcccb456b8b96fc3a2e4efddf4f48c651e992f91aba7f7092", "app": { - "archivo": "OrganizadorLabstream-app-2.1.0.zip", - "size": 14878256, - "sha256": "381cbd045a79c4ed920d4101aabb8b234034f93b99b816732bc8b5c2dd7d73b1", - "url": "/api/herramientas/OrganizadorLabstream-app-2.1.0.zip" + "archivo": "OrganizadorLabstream-app-2.2.0.zip", + "size": 14880084, + "sha256": "e1baad7197559579c3e202f15a2f857b973c696acbb078be19302e7e3e5ed84f", + "url": "/api/herramientas/OrganizadorLabstream-app-2.2.0.zip" }, "notes": "", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "944a227ccc3edf2bc3038ca2d6ab507efac49c5b3384df1a6659073e9a88e84e853b73617e47f03ad53bbac04a24bb37082d38bdc918b6c22bd8c93158d75ec34190abe1fa082b8eb0bf3efb80a42dd80bbac6e1b28d839675654f674e82ab8023eb10b76d1df198cb14bcd847edee12b74f89851160937768981be8e327ae151b94bf1c38339d1e0210a653d593f54d26a3c6140f0401dcfe0b039fbf20b188a14f263f19242824f1c351f3837c5bd07d486a0c5e18376f3c30e2e1e61013e18b66883b659a326f5c38a875527372013ec111c579ddd50ee7d3c4aa4fe757971b3b022cc9ea004472db397d5ab189a29c5450ba8b7b43a8c5f767b0b7d3087e" + "valor": "663c5b40876996a24ce05dfe9df6a9f71f8a7391b3f2a6ad15865b95038f12eb42836d0bdc42c14437568a387985f95dafe34f3c1e30700fe1f40ca851e56a7c78cf834bbcb21e3da244eecdd0805d8e5dcec754a4e7cfb8a10281e7eea7fde9dff788e3eac74fe57cb94a664a48008b77eb016439b8e1fa459b2d72fcbc168f96569dc0e41610e70b24b45bc2a1c7f282f9f30d76062ac045f623c7b82a415296aef45566de5628ccc3b9b3f7f537d854953a059279b3522d46285c1ed6c52fbf55de26fe2cabe774527f1de67eabf15096b6693f5454fd35835d06801d7837cf3022309ec3a5f0ec5eff77739d06be1e8cff271c715ae43142627b1e7482e4" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 58ba0f3..dbd4b6f 100644 --- a/organizador.py +++ b/organizador.py @@ -34,7 +34,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "2.1.0" +VERSION = "2.2.0" CONGELADO = getattr(sys, "frozen", False) SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller @@ -465,7 +465,7 @@ def escanear(ruta): 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, st.st_mtime) + fecha, origen = R.fecha_de_nombre(nombre, R.fecha_real(st)) fy, fm = int(fecha[:4]), int(fecha[5:7]) filas.append((e.path, raiz, path, nombre, ext, st.st_size, st.st_mtime, rol, firma or "", ctx or "", anio, mes, fecha, origen, basura, proxy, stock)) @@ -487,6 +487,7 @@ def escanear(ruta): try: walk(raiz, False, False, False, "", "raiz", None, None) prog["fallos"] = len(fallos) + _heredar_fechas(filas) con = db() # Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de # red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de @@ -543,6 +544,42 @@ def escanear(ruta): prog["fin"] = time.time() +def _heredar_fechas(filas): + """Un render o una conversión se crean mucho después del rodaje, así que su fecha no dice nada del + material: si el original está en la misma carpeta, el derivado toma SU fecha. + + Así «C0001_10bit.mp4» se archiva en el mes en que se grabó «C0001.MP4», no en el mes en que se + convirtió, y el mes de una carpeta se decide por el material de verdad.""" + I_RUTA, I_DIR, I_NOMBRE, I_FECHA, I_ORIGEN = 0, 2, 3, 12, 13 + por_dir = defaultdict(dict) + en_todo = defaultdict(list) + for f in filas: + tallo = os.path.splitext(f[I_NOMBRE])[0].lower() + por_dir[f[I_DIR].lower()][tallo] = f + en_todo[tallo].append(f) + heredadas = 0 + for i, f in enumerate(filas): + base = R.base_derivado(f[I_NOMBRE]) + if not base: + continue + orig = por_dir[f[I_DIR].lower()].get(base.lower()) + if orig is None and len(base) >= 6: + # el render puede estar en otra carpeta («_Exportados», «_Archivo»): vale si el nombre es + # bastante propio y no hay dos candidatos que se llamen igual en toda la carpeta del cliente + cands = en_todo.get(base.lower()) or [] + orig = cands[0] if len(cands) == 1 else None + if orig is None or orig is f or orig[I_ORIGEN] == "derivado": + continue + if orig[I_FECHA] >= f[I_FECHA]: + continue # el «original» no es más viejo: no hereda nada + nueva = list(f) + nueva[I_FECHA] = orig[I_FECHA] + nueva[I_ORIGEN] = "derivado" # la fecha viene del archivo del que salió + filas[i] = tuple(nueva) + heredadas += 1 + return heredadas + + def lanzar_escaneo(ruta): raiz = os.path.normpath(ruta) p = PROGRESO.get(raiz) @@ -810,7 +847,8 @@ class Plan: 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 parece de {R.nombre_mes(real[1])}" + 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) @@ -961,17 +999,23 @@ class Plan: elif R.detectar_mes(seg)[0]: mes_ruta = R.detectar_mes(seg)[0] if anio and mes: - # La carpeta dice un mes, pero los archivos pueden 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). Antes esto se movía - # con confianza alta y sin aviso; ahora se avisa y baja a media. + # La carpeta dice un mes y el archivo puede decir otro. Medido el 16-sep-2026 sobre el + # material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes que + # anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre de 2025). + # + # Manda el archivo, no la carpeta: el nombre lo pone la cámara y la fecha de creación la + # pone el disco, mientras que el nombre de la carpeta lo escribió una persona con prisa. + # Así no hay que preguntarle a nadie cuál es el mes bueno. ay, am, fconf = anio, mes, "alta" if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes): - fconf = "media" - desacuerdo[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1 + ra, rm = int(f["fecha"][:4]), int(f["fecha"][5:7]) + if f["fecha_origen"] == "nombre+mtime": + ra = anio if rm <= mes else anio - 1 # el nombre de la cámara no trae año + ay, am, fconf = ra, rm, "media" + desacuerdo[(ra, rm)] += 1 else: ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) - por_nombre = f["fecha_origen"] != "mtime" + por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado» fconf = "media" if por_nombre else "baja" # el año de una carpeta intermedia ("…/2023/…") manda sobre la fecha de modificación, # pero no sobre una fecha escrita en el nombre del archivo @@ -994,10 +1038,11 @@ class Plan: 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" · OJO: la carpeta dice {R.nombre_mes(mes)} pero {cuantos} de sus archivos son de " - f"{R.nombre_mes(dm)} según su propio nombre; comprueba antes de aplicar") - self.avisos.append(f"«{self.rel(e.path)}» dice {R.nombre_mes(mes)} de {anio} y el nombre de {cuantos} de " - f"sus archivos dice {R.nombre_mes(dm)}: se propone sin marcar para que lo revises.") + aviso_fecha = (f" · la carpeta decía {R.nombre_mes(mes)}, pero {cuantos} de sus archivos son de " + f"{R.nombre_mes(dm)} de {da}: van a su mes de verdad") + self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de " + f"sus archivos se grabaron en {R.nombre_mes(dm)} de {da}. Se archivan en el mes en que " + f"se grabaron, no en el que dice la carpeta.") for (ay, am, fuente, fconf), archivos in grupos.items(): sub = fuente if not etiqueta else os.path.join(etiqueta, fuente) destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, sub=sub, nombre=limpiar_nombre(nombre)) @@ -1005,9 +1050,8 @@ class Plan: 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 "")) - marcado = False if aviso_fecha else None self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf, - archivos=archivos, marcado=marcado) + archivos=archivos) def plan_archivo(self, e, nivel, anio, mes, padre): fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone() diff --git a/reglas.py b/reglas.py index 7a4442a..1ebbd4a 100644 --- a/reglas.py +++ b/reglas.py @@ -9,6 +9,7 @@ estructura canónica a la que se lleva cada cosa según el tipo de cliente. El diccionario de carpetas se siembra desde aquí la primera vez y después se guarda en datos/diccionario.json, donde la app va aprendiendo. """ +import os import re import unicodedata import datetime @@ -257,6 +258,32 @@ _RX_BM = re.compile(r"^[A-Z]\d{3}_(\d{2})(\d{2})\d{4}_C\d{3}", re.I) _RX_WA = re.compile(r"WhatsApp \w+ (20\d{2})-(\d{2})-(\d{2})", re.I) +# sufijos que la propia app (o el editor) le pone a un archivo derivado de otro: el derivado se +# genera mucho después, pero la fecha que importa es la del material original +SUFIJOS_DERIVADO = ("_10bit", "_ligero", "_h264", "_proxy", "_lt", "_hq", "_prores", "_convertido", + "_export", "_render", "_master", "_final") + + +def base_derivado(nombre: str): + """Nombre del archivo del que este parece salir, o None. «C0001_10bit.mp4» -> «C0001».""" + base = os.path.splitext(nombre)[0] + b = base.lower() + for suf in SUFIJOS_DERIVADO: + if b.endswith(suf) and len(base) > len(suf): + return base[:-len(suf)] + return None + + +def fecha_real(st): + """El instante más antiguo que el disco sabe de este archivo. + + Al copiar en Windows, la fecha de creación pasa a ser la de la copia y la de modificación se + conserva; al renderizar o reexportar, las dos son nuevas. Quedarse con la más antigua de las dos + da siempre la mejor estimación de cuándo se grabó, sin tener que elegir una de antemano.""" + c = getattr(st, "st_birthtime", None) or st.st_ctime + return min(st.st_mtime, c) if c and c > 0 else st.st_mtime + + def fecha_de_nombre(nombre: str, mtime: float): """(YYYY-MM-DD, origen) — origen 'nombre' si sale del nombre, 'mtime' si no.""" for rx in (_RX_YMD, _RX_YMD_HMS, _RX_Y_M_D, _RX_WA):