2.2.0: la fecha de verdad manda, sin preguntar nada

- R.fecha_real: la fecha sale de lo más antiguo que sabe el disco. Al copiar en
  Windows la creación pasa a ser la de la copia y la modificación se conserva;
  al renderizar, las dos son nuevas.
- R.base_derivado y _heredar_fechas: un render hereda la fecha del original, en
  la misma carpeta o en otra si el nombre es propio y único. Si hay dos
  candidatos iguales no hereda.
- El plan archiva el material en el mes en que se grabó, no en el que dice la
  carpeta, y explica lo que hizo en vez de pedir una decisión. Los 727 GB de
  las dos carpetas que mentían se resuelven solos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 22:01:33 -05:00
padre 1e1187d28a
commit 99d0ba2034
Se han modificado 4 ficheros con 113 adiciones y 26 borrados

Ver fichero

@@ -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 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. 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 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ó 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: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "2.1.0", "version": "2.2.0",
"builtAt": "2026-09-17T02:51:40.000Z", "builtAt": "2026-09-17T02:58:48.000Z",
"archivo": "OrganizadorLabstream-portatil-2.1.0.zip", "archivo": "OrganizadorLabstream-portatil-2.2.0.zip",
"size": 198698005, "size": 198699833,
"sha256": "1ba2cd2dde181b42c6de5cc0860fa52d6d201c874bb1b28285200f984c240bf4", "sha256": "89d8437d47c05d9fcccb456b8b96fc3a2e4efddf4f48c651e992f91aba7f7092",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-2.1.0.zip", "archivo": "OrganizadorLabstream-app-2.2.0.zip",
"size": 14878256, "size": 14880084,
"sha256": "381cbd045a79c4ed920d4101aabb8b234034f93b99b816732bc8b5c2dd7d73b1", "sha256": "e1baad7197559579c3e202f15a2f857b973c696acbb078be19302e7e3e5ed84f",
"url": "/api/herramientas/OrganizadorLabstream-app-2.1.0.zip" "url": "/api/herramientas/OrganizadorLabstream-app-2.2.0.zip"
}, },
"notes": "", "notes": "",
"firma": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "944a227ccc3edf2bc3038ca2d6ab507efac49c5b3384df1a6659073e9a88e84e853b73617e47f03ad53bbac04a24bb37082d38bdc918b6c22bd8c93158d75ec34190abe1fa082b8eb0bf3efb80a42dd80bbac6e1b28d839675654f674e82ab8023eb10b76d1df198cb14bcd847edee12b74f89851160937768981be8e327ae151b94bf1c38339d1e0210a653d593f54d26a3c6140f0401dcfe0b039fbf20b188a14f263f19242824f1c351f3837c5bd07d486a0c5e18376f3c30e2e1e61013e18b66883b659a326f5c38a875527372013ec111c579ddd50ee7d3c4aa4fe757971b3b022cc9ea004472db397d5ab189a29c5450ba8b7b43a8c5f767b0b7d3087e" "valor": "663c5b40876996a24ce05dfe9df6a9f71f8a7391b3f2a6ad15865b95038f12eb42836d0bdc42c14437568a387985f95dafe34f3c1e30700fe1f40ca851e56a7c78cf834bbcb21e3da244eecdd0805d8e5dcec754a4e7cfb8a10281e7eea7fde9dff788e3eac74fe57cb94a664a48008b77eb016439b8e1fa459b2d72fcbc168f96569dc0e41610e70b24b45bc2a1c7f282f9f30d76062ac045f623c7b82a415296aef45566de5628ccc3b9b3f7f537d854953a059279b3522d46285c1ed6c52fbf55de26fe2cabe774527f1de67eabf15096b6693f5454fd35835d06801d7837cf3022309ec3a5f0ec5eff77739d06be1e8cff271c715ae43142627b1e7482e4"
} }
} }

Ver fichero

@@ -34,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "2.1.0" VERSION = "2.2.0"
CONGELADO = getattr(sys, "frozen", False) 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 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 basura = 1 if (en_basura or rol == "basura") else 0
proxy = 1 if (en_proxy or rol == "proxy") else 0 proxy = 1 if (en_proxy or rol == "proxy") else 0
stock = 1 if (en_stock or rol == "stock") 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]) 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 "", 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)) ctx or "", anio, mes, fecha, origen, basura, proxy, stock))
@@ -487,6 +487,7 @@ def escanear(ruta):
try: try:
walk(raiz, False, False, False, "", "raiz", None, None) walk(raiz, False, False, False, "", "raiz", None, None)
prog["fallos"] = len(fallos) prog["fallos"] = len(fallos)
_heredar_fechas(filas)
con = db() con = db()
# Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de # 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 # 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() 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): def lanzar_escaneo(ruta):
raiz = os.path.normpath(ruta) raiz = os.path.normpath(ruta)
p = PROGRESO.get(raiz) p = PROGRESO.get(raiz)
@@ -810,7 +847,8 @@ class Plan:
if nombre != canon: if nombre != canon:
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "") motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "")
if discrepa: 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, self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo,
"media" if (conflicto or discrepa) else "alta") "media" if (conflicto or discrepa) else "alta")
self.plan_dir(e.path, "mes", anio, m) self.plan_dir(e.path, "mes", anio, m)
@@ -961,17 +999,23 @@ class Plan:
elif R.detectar_mes(seg)[0]: elif R.detectar_mes(seg)[0]:
mes_ruta = R.detectar_mes(seg)[0] mes_ruta = R.detectar_mes(seg)[0]
if anio and mes: if anio and mes:
# La carpeta dice un mes, pero los archivos pueden decir otro. Medido el 16-sep-2026 sobre # La carpeta dice un mes y el archivo puede decir otro. Medido el 16-sep-2026 sobre el
# el material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes # material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes que
# que anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre). Antes esto se movía # anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre de 2025).
# con confianza alta y sin aviso; ahora se avisa y baja a media. #
# 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" ay, am, fconf = anio, mes, "alta"
if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes): if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
fconf = "media" ra, rm = int(f["fecha"][:4]), int(f["fecha"][5:7])
desacuerdo[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1 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: else:
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) 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" fconf = "media" if por_nombre else "baja"
# el año de una carpeta intermedia ("…/2023/…") manda sobre la fecha de modificación, # 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 # 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") 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): 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] (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 " aviso_fecha = (f" · la carpeta decía {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") f"{R.nombre_mes(dm)} de {da}: van a su mes de verdad")
self.avisos.append(f"«{self.rel(e.path)}» dice {R.nombre_mes(mes)} de {anio} y el nombre de {cuantos} de " self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de "
f"sus archivos dice {R.nombre_mes(dm)}: se propone sin marcar para que lo revises.") 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(): for (ay, am, fuente, fconf), archivos in grupos.items():
sub = fuente if not etiqueta else os.path.join(etiqueta, fuente) 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)) 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): if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
continue 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 "")) 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, 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): def plan_archivo(self, e, nivel, anio, mes, padre):
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone() fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()

Ver fichero

@@ -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 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. se guarda en datos/diccionario.json, donde la app va aprendiendo.
""" """
import os
import re import re
import unicodedata import unicodedata
import datetime 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) _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): def fecha_de_nombre(nombre: str, mtime: float):
"""(YYYY-MM-DD, origen) — origen 'nombre' si sale del nombre, 'mtime' si no.""" """(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): for rx in (_RX_YMD, _RX_YMD_HMS, _RX_Y_M_D, _RX_WA):