Cerrar lo para todo, continuar sigue por donde iba, convertir antes de ordenar, y lo grande a la vista

Munka: «que al cerrar cierre todos los procesos… que al continuar la cola siga por donde iba y vuelva a generar los
videos que hagan falta… que identifique bien el material… y convertir antes de mover». Cerrar con trabajo avisa y
lo para todo (cola, lectura, respaldo, traer), cierra lo que quede con lo que lanzó y borra lo que quedó a medias.
Lo cortado vuelve a esperar en su sitio (no «cancelado») y al continuar se hace entero; también vuelven los fallos
por un corte y los convertidos que ya no están. Actualizar con la cola en marcha la detiene y sigue sola al volver.
El Convertidor enseña lo grande de la carpeta y qué pasa con cada parte (exportados sin incluir, con un botón).
Convertir va antes de ordenar en los pasos y en Inicio; lo que se está convirtiendo no se mueve, y una carpeta con
algo abierto ya no se copia entera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 17:55:10 -05:00
padre 00b84b4aa7
commit 58197ffd67
Se han modificado 7 ficheros con 387 adiciones y 45 borrados

Ver fichero

@@ -1045,7 +1045,8 @@ class Convertidor(unittest.TestCase):
hilo.join(40)
self.assertFalse(hilo.is_alive())
t = self.trabajo(ids[2])
self.assertEqual(t["estado"], "cancelado")
self.assertEqual(t["estado"], "pendiente") # vuelve a esperar: al continuar se hace entero
self.assertTrue(t["log"].startswith("se detuvo a mitad"), t["log"])
self.assertFalse(os.path.exists(t["salida"]) or os.path.exists(t["salida"] + ".parcial.mp4"))
self.assertEqual([x for x in os.listdir(O.DATOS) if x.startswith("braw_")], [])
@@ -1317,8 +1318,9 @@ class Convertidor(unittest.TestCase):
self.assertTrue(os.path.isfile(os.path.join(nueva, os.path.basename(tb["salida"]))))
def _cola_falsa(self, durante=0.4):
"""Sustituye la conversión por una que tarda `durante` segundos y cuenta cuántas van a la vez."""
cuenta = {"vivos": 0, "maximo": 0, "vistos": []}
"""Sustituye la conversión por una que tarda cuenta["durante"] segundos (deja su «.parcial» mientras) y cuenta
cuántas van a la vez."""
cuenta = {"vivos": 0, "maximo": 0, "vistos": [], "durante": durante}
candado = threading.Lock()
def falso(t):
@@ -1326,14 +1328,19 @@ class Convertidor(unittest.TestCase):
cuenta["vivos"] += 1
cuenta["maximo"] = max(cuenta["maximo"], cuenta["vivos"])
cuenta["vistos"].append(t["id"])
fin = time.time() + durante
with open(t["salida"] + ".parcial.mp4", "wb") as fh:
fh.write(b"p" * 100)
O.set_trabajo(t["id"], pct=40)
fin = time.time() + cuenta["durante"]
while time.time() < fin and not O.COLA["cancelar"]:
time.sleep(0.02)
with candado:
cuenta["vivos"] -= 1
if O.COLA["cancelar"]:
O.set_trabajo(t["id"], estado="cancelado", pct=0)
O._quitar_parcial(t["salida"] + ".parcial.mp4")
O._devolver_a_la_cola(t["id"])
return
os.remove(t["salida"] + ".parcial.mp4")
with open(t["salida"], "wb") as fh:
fh.write(b"x" * 1000)
O.set_trabajo(t["id"], estado="listo", pct=100, bytes_out=1000, fin=time.time())
@@ -1360,7 +1367,7 @@ class Convertidor(unittest.TestCase):
con = O.db()
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
with con:
con.execute("UPDATE trabajos SET estado = 'cancelado' WHERE estado = 'pendiente'")
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
con.close()
def devolver():
@@ -1389,13 +1396,128 @@ class Convertidor(unittest.TestCase):
O.COLA["cancelar"], O.COLA["activa"] = True, False # lo que hace «Detener»
O.COLA["hilo"].join(30)
estados = sorted(self.trabajo(i)["estado"] for i in ids)
self.assertEqual(estados, ["cancelado", "cancelado", "pendiente"])
self.assertEqual(estados, ["pendiente", "pendiente", "pendiente"]) # los cortados vuelven a esperar
self.assertFalse(O.COLA["cancelar"])
con = O.db()
with con:
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
con.close()
def test_cerrar_para_todo_y_continuar(self):
"""Munka, 14-oct-2026: «que al cerrar cierre todos los procesos, hasta las colas que faltan y los que quedan a
mitad; que al continuar una cola la continúe y vuelva a generar los videos que hagan falta, por donde iba»."""
raiz = cliente("CerrarTodo")
nr = os.path.normpath(raiz)
g = "2025/11- Noviembre/00_Grabacion/SONY/"
clips = [mk(raiz, g + f"C01{i:02d}.MP4", 2025, 11, 11, tam=310000 + i) for i in range(3)]
O.escanear(raiz)
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
cuenta = self._cola_falsa(durante=30)
O.CONFIG["optimizar"]["simultaneas"] = 2
con = O.db()
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
with con:
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
con.close()
def devolver():
c = O.db()
with c:
c.executemany("UPDATE trabajos SET estado = 'pendiente' WHERE id = ?", [(i,) for i in otros])
c.close()
self.addCleanup(devolver)
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
self.assertTrue(O.iniciar_cola())
fin = time.time() + 10
while time.time() < fin and len(cuenta["vistos"]) < 2:
time.sleep(0.05)
parciales = [self.trabajo(i)["salida"] + ".parcial.mp4" for i in cuenta["vistos"]]
self.assertTrue(all(os.path.exists(p) for p in parciales))
# cerrar (o actualizar): para todo, lo que iba vuelve a esperar sin su archivo a medias
t0 = time.time()
r = O.detener_todo(seguir=True)
self.assertLess(time.time() - t0, 10)
self.assertEqual((r["cola"], r["pendientes"]), (True, 3))
self.assertFalse(O.COLA["activa"] or O.COLA["actuales"])
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente"] * 3)
self.assertTrue(all(self.trabajo(i)["log"].startswith("se detuvo a mitad (iba al 40 %)") for i in cuenta["vistos"]))
self.assertFalse(any(os.path.exists(p) for p in parciales))
self.assertTrue(os.path.exists(O._ruta_seguir()))
# al volver a abrir, la cola sigue sola por donde iba: primero los que se cortaron
cuenta.update(vistos=[], durante=0.2)
primeros = sorted(ids)[:2]
self.assertTrue(O._seguir_al_abrir())
self.assertFalse(os.path.exists(O._ruta_seguir()))
O.COLA["hilo"].join(30)
self.assertEqual(sorted(cuenta["vistos"][:2]), primeros)
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["listo"] * 3)
self.assertFalse(O._seguir_al_abrir()) # sin nada pendiente de seguir, no arranca
def test_al_continuar_se_rehace_lo_que_falta(self):
"""Lo cortado por un «Detener» de antes (cancelado), lo que falló por un corte («fallo inesperado») y el
convertido que ya no está vuelven a la cola; un error de verdad y lo que sigue en su sitio, no."""
raiz = cliente("Rehacer")
nr = os.path.normpath(raiz)
g = "2025/11- Noviembre/00_Grabacion/SONY/"
clips = [mk(raiz, g + f"C02{i:02d}.MP4", 2025, 11, 11, tam=320000 + i) for i in range(5)]
O.escanear(raiz)
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
O.set_trabajo(ids[0], estado="cancelado")
O.set_trabajo(ids[1], estado="error", log="fallo inesperado: [WinError 5] Acceso denegado: braw_961.json")
O.set_trabajo(ids[2], estado="error", log="el decodificador no pudo abrir el clip")
for i in ids[3:]:
O.set_trabajo(i, estado="verificado", bytes_out=1000)
with open(self.trabajo(ids[4])["salida"], "wb") as fh:
fh.write(b"x" * 1000)
self.assertEqual(O.recuperar_huerfanas(), 2)
self.assertEqual(O.rehacer_perdidos(), 1)
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente", "pendiente", "error", "pendiente", "verificado"])
self.assertEqual(self.trabajo(ids[3])["log"], "el convertido ya no estaba: se vuelve a hacer")
con = O.db()
with con:
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
con.close()
def test_no_se_mueve_lo_que_se_convierte(self):
"""«Si muevo y luego convierto no funciona bien»: lo que espera en la cola se mueve y la cola lo sigue, pero
lo que se está convirtiendo ahora (ffmpeg lo tiene abierto) se queda, diciendo por qué. Y una carpeta con
algo abierto ya no se copia entera para luego dejarla a medias."""
raiz = cliente("NoMover")
nr = os.path.normpath(raiz)
a = mk(raiz, "Material Sony/20250722_Labstream5001.MP4", 2025, 7, 22, tam=300000)
b = mk(raiz, "Material Sony/20250731_Labstream5002.MP4", 2025, 7, 31, 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)
O.set_trabajo(ia, estado="corriendo")
p = O.Plan(O.raiz_cfg(raiz)).correr()
r = O.aplicar(raiz, [f for f in p["filas"] if f["marcado"]])
self.assertTrue(os.path.exists(a)) # el que se convierte, en su sitio
self.assertFalse(os.path.exists(b)) # el que espera, movido (y la cola con él)
self.assertEqual(r["motivos"]["convirtiendo"]["n"], 1)
self.assertIn("00_Grabacion", self.trabajo(ib)["ruta"])
con = O.db()
with con:
con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib))
con.close()
# una carpeta con un archivo abierto: no se copia
d = os.path.join(nr, "Abierta")
os.makedirs(d)
f = os.path.join(d, "clip.mov")
with open(f, "wb") as fh:
fh.write(b"x" * 1000)
abierto = open(f, "rb")
self.addCleanup(abierto.close)
try:
O.mover(d, os.path.join(nr, "Destino Abierta"), "prueba", [])
except OSError as ex:
self.assertIn("en uso", str(ex))
self.assertFalse(os.path.exists(os.path.join(nr, "Destino Abierta")))
self.assertTrue(os.path.isfile(f))
else:
self.skipTest("este disco deja renombrar una carpeta con un archivo abierto")
def test_conversiones_sueltas(self):
"""14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de antes de actualizar seguían colgados siete
horas después. Al arrancar se cierran los que no tienen quien los espere, y no los de un programa vivo."""