diff --git a/README.md b/README.md index d885383..5673ae4 100644 --- a/README.md +++ b/README.md @@ -932,7 +932,26 @@ se convierta, la idea es aprovechar lo máximo el espacio». - El descarte después de convertir sigue al mínimo: se descarta lo que ahorre menos del mínimo menos 10 puntos (con el 30 %, menos del 20 %, como hasta ahora). -Pruebas: `prueba_orden.py` 39 casos (el de «lo ya comprimido» pasa a ser el del 30 %: H.265 holgado y justo, H.264 +Y lo que salió al cotejar, archivo por archivo, cada disco con el inventario (Munka: «quiero saber si están todos +los archivos del disco, si hizo bien su escaneo»). El escaneo estaba completo: en los cinco clientes no faltaba +ningún archivo que existiera al escanear (lo que no estaba eran los convertidos hechos después, y en Diana Méndez +lo que alguien vació de _Para borrar). Pero había tres cosas: + +- **Las mayúsculas de las carpetas.** Windows no distingue «Altas Print» de «ALTAS PRINT». Si el plan movía a la + primera y en el disco estaba la segunda, el archivo caía en la que había, pero el inventario apuntaba la ruta + del plan: en Vía Sumapaz, 1.039 fotos con la carpeta mal escrita, y «Altas print», «Altas Print» y «ALTAS + PRINT» como tres carpetas donde en el disco hay una. Ahora se escribe la carpeta como está en el disco, y las + carpetas se recuentan donde quedaron al final de la tanda. +- **Mover mientras se escanea.** Un proxy que la app apartó a _Para borrar a las 10:02, en pleno escaneo de Vía + Sumapaz, quedó dos veces en el inventario. Ahora, si la app mueve algo de una carpeta mientras se recorre, se + recorre otra vez (hasta tres; si sigue moviéndose, lo avisa). +- **Lo ya convertido, dos veces.** Con la 3.7 los 11 verificados de Línea Gene se volvieron a encolar en su sitio + nuevo junto con los 136 que faltaban, y uno ya se había convertido otra vez. Ahora, al reengancharlos, la + conversión que aún espera se quita; la que ya se hizo deja «no compensa» a la vieja, y «Limpiar terminados» + aparta su archivo. + +Pruebas: `prueba_orden.py` 42 casos (tres nuevos: las mayúsculas del disco, escanear mientras se mueve, y lo ya +convertido que se volvió a encolar; el de «lo ya comprimido» pasa a ser el del 30 %: H.265 holgado y justo, H.264 por debajo del objetivo, AV1 de móvil, Duplicados, el ajuste al 45 % y el descarte); `prueba_funcional.py` 33 de 33, `probar.py` 19 y `prueba_ventana.py` 18. diff --git a/organizador.py b/organizador.py index 9a3b7c4..bc083b7 100644 --- a/organizador.py +++ b/organizador.py @@ -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.""" diff --git a/prueba_orden.py b/prueba_orden.py index 307e510..5300be7 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -501,6 +501,60 @@ class Motor(unittest.TestCase): self.assertEqual(R.firma_archivo("C0001_arch.mp4"), "SONY") self.assertTrue(R.es_conversion("C0001_10bit.mp4")) # y los de siempre siguen valiendo + def test_el_inventario_escribe_la_carpeta_como_el_disco(self): + """Vía Sumapaz, 14-oct-2026: el plan movió fotos a «Altas Print» y «Altas print», que en el disco son la misma + «ALTAS PRINT»; el inventario apuntó tres carpetas distintas con 1.039 fotos mal escritas.""" + raiz = cliente("Mayusculas") + nr = os.path.normpath(raiz) + a = mk(raiz, "Sueltas/foto1.jpg", 2024, 5, 3) + b = mk(raiz, "Sueltas/foto2.jpg", 2024, 5, 3) + mk(raiz, "2024/05- Mayo/_Fotos/ALTAS PRINT/foto0.jpg", 2024, 5, 3) + O.escanear(raiz) + movidos = [] + O.mover(a, os.path.join(nr, "2024", "05- Mayo", "_Fotos", "Altas Print", "foto1.jpg"), "prueba", movidos) + O.mover(b, os.path.join(nr, "2024", "05- mayo", "_fotos", "altas print", "foto2.jpg"), "prueba", movidos) + bien = os.path.join(nr, "2024", "05- Mayo", "_Fotos", "ALTAS PRINT") + self.assertEqual([d for _, d in movidos], [os.path.join(bien, "foto1.jpg"), os.path.join(bien, "foto2.jpg")]) + O.actualizar_inventario(movidos) + con = O.db() + rutas = {r[0] for r in con.execute("SELECT ruta FROM archivos WHERE raiz = ?", (nr,))} + carpetas = [r[0] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ? AND lower(ruta) = lower(?)", (nr, bien))] + con.close() + self.assertTrue({os.path.join(bien, n) for n in ("foto0.jpg", "foto1.jpg", "foto2.jpg")} <= rutas, rutas) + self.assertEqual(carpetas, [bien]) + # una carpeta que no existía se crea como la escribe el plan, y las siguientes la siguen igual + c = mk(raiz, "Sueltas/foto3.jpg", 2024, 5, 3) + d = mk(raiz, "Sueltas/foto4.jpg", 2024, 5, 3) + movidos = [] + O.mover(c, os.path.join(nr, "Nueva Carpeta", "foto3.jpg"), "prueba", movidos) + O.mover(d, os.path.join(nr, "NUEVA CARPETA", "foto4.jpg"), "prueba", movidos) + self.assertEqual({os.path.dirname(x) for _, x in movidos}, {os.path.join(nr, "Nueva Carpeta")}) + + def test_escaneo_con_movimientos_a_la_vez(self): + """Vía Sumapaz, 14-oct-2026: un proxy apartado a _Para borrar en pleno escaneo quedó dos veces en el + inventario (visto en su sitio y luego en la papelera). Si la app mueve algo mientras se recorre, se recorre otra vez.""" + raiz = cliente("EnMarcha") + nr = os.path.normpath(raiz) + p = mk(raiz, "2024/09- Septiembre/03- Botellitas/Proxy/Botellas Vr2.mov", 2024, 9, 3) + mk(raiz, "2024/09- Septiembre/03- Botellitas/Botellas Vr2.mov", 2024, 9, 3, tam=4096) + real = O._fechas_de_rodaje + llamadas = [] + + def a_la_vez(filas): + llamadas.append(len(filas)) + if len(llamadas) == 1: # después de recorrer, antes de guardar: la app aparta el proxy + O.mover(p, os.path.join(nr, R.PAPELERA, "proxies", os.path.relpath(p, nr)), "aparte", []) + return real(filas) + O._fechas_de_rodaje = a_la_vez + self.addCleanup(setattr, O, "_fechas_de_rodaje", real) + O.escanear(raiz) + self.assertEqual(len(llamadas), 2) + con = O.db() + proxies = [r[0] for r in con.execute("SELECT ruta FROM archivos WHERE raiz = ? AND nombre = 'Botellas Vr2.mov' AND bytes = 2048", (nr,))] + con.close() + self.assertEqual(proxies, [os.path.join(nr, R.PAPELERA, "proxies", os.path.relpath(p, nr))]) + self.assertEqual(O.PROGRESO[nr]["estado"], "listo") + def test_inventario_de_otra_version(self): """Lo que apunta el escaneo cambió: un inventario viejo se relee antes de planear nada.""" raiz = cliente("Viejo") @@ -1214,6 +1268,53 @@ class Convertidor(unittest.TestCase): self.assertTrue(os.path.isfile(os.path.join(nr, R.PAPELERA, "originales", "Otra carpeta", os.path.basename(c2)))) self.assertEqual(self.trabajo(ic)["salida"], junto) + def test_lo_ya_convertido_no_se_convierte_dos_veces(self): + """Línea Gene, 14-oct-2026: con la 3.7 los 11 verificados se volvieron a encolar en su sitio nuevo, y uno ya + se había convertido otra vez. Al reengancharlos: la conversión que aún espera sobra; la ya hecha deja de más + la vieja.""" + raiz = cliente("DosVeces") + nr = os.path.normpath(raiz) + a = mk(raiz, "Material Sony/20250722_Labstream3805.MP4", 2025, 7, 22, tam=300000) + b = mk(raiz, "Material Sony/20250722_Labstream3766.MP4", 2025, 7, 22, tam=300001) + O.escanear(raiz) + self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b)}) + ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr) + for i in (ia, ib): + with open(self.trabajo(i)["salida"], "wb") as fh: + fh.write(b"x" * 90000) + O.set_trabajo(i, estado="verificado", bytes_out=90000) + # la 3.7: los clips se movieron, la cola no se enteró, y se volvieron a encolar en el sitio nuevo + nueva = os.path.join(nr, "2025", "07- Julio", "00_Grabacion", "22- Martes", "SONY") + os.makedirs(nueva) + a2, b2 = os.path.join(nueva, os.path.basename(a)), os.path.join(nueva, os.path.basename(b)) + os.rename(a, a2) + os.rename(b, b2) + with open(os.path.splitext(a2)[0] + "_10bit.mp4", "wb") as fh: # el de a ya se convirtió otra vez allí + fh.write(b"y" * 90000) + con = O.db() + with con: + ids = [] + for ruta, estado in ((a2, "verificado"), (b2, "pendiente")): + ids.append(con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, bytes_out, creado, raiz, formato) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (ruta, os.path.splitext(ruta)[0] + "_10bit.mp4", "ffmpeg", "equilibrado", "420", + "rec709", 21150, estado, os.path.getsize(ruta), 90000, time.time(), nr, + "h265_10bit")).lastrowid) + con.close() + O.escanear(raiz) + ta, tb = self.trabajo(ia), self.trabajo(ib) + # el de a ya se había vuelto a convertir: sobra el viejo, que queda «no compensa» con su porqué + self.assertEqual(ta["estado"], "descartado") + self.assertIn("se volvió a convertir en su sitio nuevo", ta["log"]) + self.assertEqual(self.trabajo(ids[0])["estado"], "verificado") + # el de b aún esperaba: se quita de la cola, y el viejo se reengancha (se reemplaza junto a su original) + con = O.db() + self.assertIsNone(con.execute("SELECT id FROM trabajos WHERE id = ?", (ids[1],)).fetchone()) + con.close() + self.assertEqual((tb["estado"], tb["ruta"]), ("verificado", b2)) + r = O.reemplazar([ib])[0] + self.assertTrue(r["ok"], r) + self.assertTrue(os.path.isfile(os.path.join(nueva, os.path.basename(tb["salida"])))) + def test_el_codificador_que_toca(self): fm = O.FORMATOS["h265_10bit"] t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000}