Inventario fiel al disco: mayúsculas de las carpetas, mover durante el escaneo, y no convertir dos veces

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>
Este commit está contenido en:
Labstream
2026-10-14 14:48:55 -05:00
padre eafd1984f3
commit 8ef3af6ad7
Se han modificado 3 ficheros con 249 adiciones y 20 borrados

Ver fichero

@@ -615,18 +615,31 @@ def escanear(ruta):
continue
try:
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
# 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
@@ -679,6 +692,8 @@ def escanear(ruta):
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
@@ -2280,8 +2295,18 @@ class Plan:
# 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")
@@ -2325,10 +2350,50 @@ def _renombrar(origen, destino, intentos=4):
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():
@@ -2337,10 +2402,12 @@ def mover(origen, destino, lote, movidos):
# 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
if os.path.isdir(origen) and os.path.isdir(destino):
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.
@@ -2382,6 +2449,8 @@ def mover(origen, destino, lote, movidos):
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))
@@ -2524,7 +2593,7 @@ def actualizar_inventario(movidos):
# 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(movidos)
tocadas = _tocadas(_rutas_finales(movidos))
existe = _existen({d for d, _ in tocadas})
cola = _cola_tras_mover(con, movidos, marcas)
with con:
@@ -2644,8 +2713,8 @@ def reubicar_trabajos(raiz=None, ids=None):
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") for r in con.execute(
"SELECT ruta, formato FROM trabajos WHERE estado IN ('pendiente','corriendo','listo','verificado','reemplazado')")}
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:
@@ -2653,10 +2722,24 @@ def reubicar_trabajos(raiz=None, ids=None):
if not sitio or len(sitio) != 1:
continue
nueva, fmt = sitio[0], t["formato"] or "h265_10bit"
if (nueva, fmt) in ocupadas and t["estado"] in ("pendiente", "error", "cancelado"):
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 (nueva, fmt) in ocupadas:
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"):
@@ -2667,7 +2750,7 @@ def reubicar_trabajos(raiz=None, ids=None):
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.add((nueva, fmt))
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"]))
@@ -2705,6 +2788,32 @@ def _son_archivos(rutas):
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."""