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

@@ -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 - 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). (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 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. 33, `probar.py` 19 y `prueba_ventana.py` 18.

Ver fichero

@@ -615,18 +615,31 @@ def escanear(ruta):
continue continue
try: try:
walk(raiz, False, False, False, "", "raiz", None, None) # Si mientras se recorre la carpeta la app mueve algo de ella («Aplicar», apartar, la cola), lo recogido
prog["fallos"] = len(fallos) # mezcla el antes y el después: un archivo visto en su sitio y luego otra vez en la papelera, o en
_fechas_de_rodaje(filas) # ninguno. Medido el 14-oct-2026 en Vía Sumapaz: un proxy apartado a las 10:02, en pleno escaneo, quedó
_heredar_fechas(filas) # dos veces en el inventario. Entonces se vuelve a recorrer (hasta tres veces).
# de qué mes es cada carpeta: se cuenta al final, con las fechas ya corregidas, y sin la basura for vuelta in range(3):
# ni los proxies (un .DS_Store de otro año no dice nada del material) movimientos_antes = _TOCADO.get(raiz.lower(), 0)
for f in filas: walk(raiz, False, False, False, "", "raiz", None, None)
if f[14] or f[15]: prog["fallos"] = len(fallos)
continue _fechas_de_rodaje(filas)
am = (int(f[12][:4]), int(f[12][5:7])) _heredar_fechas(filas)
for d in subir(f[2]): # de qué mes es cada carpeta: se cuenta al final, con las fechas ya corregidas, y sin la basura
dirs[d][2][am] += 1 # 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() 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
@@ -679,6 +692,8 @@ def escanear(ruta):
prog["estado"] = "listo" prog["estado"] = "listo"
if fallos: if fallos:
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})" 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: try:
reubicar_trabajos(raiz) # la cola encuentra los clips que se movieron desde el último escaneo reubicar_trabajos(raiz) # la cola encuentra los clips que se movieron desde el último escaneo
except Exception: # noqa except Exception: # noqa
@@ -2280,8 +2295,18 @@ class Plan:
# Aplicar el plan (con bitácora y deshacer) # 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): def bitacora(entrada):
entrada["ts"] = time.time() 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: with open(BITACORA_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entrada, ensure_ascii=False) + "\n") 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)) 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): def mover(origen, destino, lote, movidos):
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona""" """mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
origen = os.path.normpath(origen) origen = os.path.normpath(origen)
destino = os.path.normpath(destino) 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: if origen == destino:
return return
if origen.lower() == destino.lower(): 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 # 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. # nunca ocurría: os.rename sí sabe hacerlo en NTFS.
_renombrar(origen, destino) _renombrar(origen, destino)
_olvidar_mayusculas()
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino}) bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, destino)) movidos.append((origen, destino))
return 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), # 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 # 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. # vaciada, con el aviso hablando de un solo archivo.
@@ -2382,6 +2449,8 @@ def mover(origen, destino, lote, movidos):
except OSError as ex: except OSError as ex:
os.remove(destino) os.remove(destino)
raise OSError(f"«{os.path.basename(origen)}» está en uso (¿abierto en Resolve o Premiere?); no se mueve") from ex 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}) bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, 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 # 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 # 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). # 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}) existe = _existen({d for d, _ in tocadas})
cola = _cola_tras_mover(con, movidos, marcas) cola = _cola_tras_mover(con, movidos, marcas)
with con: 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): 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"]: if os.sep + R.DUPLICADOS + os.sep not in r["ruta"]:
donde[((r["raiz"] or "").lower(), r["nombre"], r["bytes"])].append(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( ocupadas = {(r["ruta"], r["formato"] or "h265_10bit"): (r["id"], r["estado"]) for r in con.execute(
"SELECT ruta, formato FROM trabajos WHERE estado IN ('pendiente','corriendo','listo','verificado','reemplazado')")} "SELECT id, ruta, formato, estado FROM trabajos WHERE estado IN ('pendiente','corriendo','listo','verificado','reemplazado')")}
n = 0 n = 0
with con: with con:
for t in sin_clip: for t in sin_clip:
@@ -2653,10 +2722,24 @@ def reubicar_trabajos(raiz=None, ids=None):
if not sitio or len(sitio) != 1: if not sitio or len(sitio) != 1:
continue continue
nueva, fmt = sitio[0], t["formato"] or "h265_10bit" 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"],)) con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],))
continue 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 continue
c = {"ruta": nueva} c = {"ruta": nueva}
if t["estado"] in ("pendiente", "error", "cancelado"): 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í" 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"])) 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"): 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 n += 1
for t in sin_salida: for t in sin_salida:
sitio = donde.get(((t["raiz"] or "").lower(), os.path.basename(t["salida"] or ""), t["bytes_out"])) 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))) 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): def _tocadas(movidos):
"""Las carpetas cuyo recuento cambia con estos movimientos: las de origen y destino y todas las de encima. El """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.""" destino mismo entra siempre: si era un archivo, el disco dirá que no es una carpeta y no se apunta nada."""

Ver fichero

@@ -501,6 +501,60 @@ class Motor(unittest.TestCase):
self.assertEqual(R.firma_archivo("C0001_arch.mp4"), "SONY") self.assertEqual(R.firma_archivo("C0001_arch.mp4"), "SONY")
self.assertTrue(R.es_conversion("C0001_10bit.mp4")) # y los de siempre siguen valiendo 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): def test_inventario_de_otra_version(self):
"""Lo que apunta el escaneo cambió: un inventario viejo se relee antes de planear nada.""" """Lo que apunta el escaneo cambió: un inventario viejo se relee antes de planear nada."""
raiz = cliente("Viejo") 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.assertTrue(os.path.isfile(os.path.join(nr, R.PAPELERA, "originales", "Otra carpeta", os.path.basename(c2))))
self.assertEqual(self.trabajo(ic)["salida"], junto) 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): def test_el_codificador_que_toca(self):
fm = O.FORMATOS["h265_10bit"] fm = O.FORMATOS["h265_10bit"]
t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000} t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000}