diff --git a/README.md b/README.md index cf90fa4..464513d 100644 --- a/README.md +++ b/README.md @@ -936,9 +936,41 @@ codificador de la RTX 5080 iba al 46-74 %, la tarjeta entera al 9-18 % y su desc - **Lo que ya no compensa no se convierte**: lo que se encoló con la regla de antes y con la del 30 % ya no se ofrecería (en Sumapaz, 21 H.265) sale de la cola al llegarle el turno, y se dice cuántos. -Pruebas: `prueba_orden.py` 46 casos (cinco nuevos: dos a la vez y «Detener», las conversiones sueltas, la tarjeta -descomprime y su vuelta al procesador si falla, lo que ya no compensa); `prueba_funcional.py` 33 de 33 (la -conversión de verdad ya descomprime con la tarjeta), `probar.py` 19 y `prueba_ventana.py` 18. +Y antes de publicarla, Munka: «que al cerrar cierre todos los procesos, hasta las colas que faltan y los que quedan a +mitad; que al continuar una cola de renderizado la continúe y vuelva a generar los videos que hagan falta, +continuando por donde iba; que identifique bien el material y haga la búsqueda de grandes archivos de video; y +colocaría convertir antes de mover los videos, dado que si muevo y luego convierto no funciona bien». + +- **Cerrar para todo.** Hasta ahora, cerrar con una conversión en marcha dejaba el programa oculto trabajando hasta + acabar, y al actualizar (que cerraba de golpe) quedaban procesos sueltos. Ahora cerrar avisa y lo para todo: la + cola, la lectura de clips, el respaldo y lo que se trae de una tarjeta (un «Aplicar» se deja acabar: son + segundos). Lo que no se cierra por las buenas se cierra con todo lo que lanzó, y su archivo a medias se borra. + Si se vuelve a abrir mientras se para, la ventana vuelve, ya sin nada en marcha. Probado con dos conversiones 4K + de verdad: se paró todo en 0,9 s, sin un ffmpeg vivo ni un «.parcial». +- **Continuar sigue por donde iba.** Lo que se corta a medias (Detener, cerrar, actualizar) vuelve a esperar en su + sitio, delante de los demás, y se hace entero otra vez (un MP4 a medias no se puede seguir); antes quedaba + «cancelado» y «Reanudar» no lo retomaba: dos BRAW cortados al actualizar se quedaron fuera de la cola. Al + continuar, también vuelve lo que falló por un corte («fallo inesperado») y los convertidos que ya no están (borrados + o movidos fuera de la app), en las carpetas conectadas. El botón se llama ahora «Continuar». +- **Actualizar con la cola en marcha.** Ya no hay que esperar: la cola se detiene, y al volver a abrirse sigue sola. +- **Lo grande, a la vista.** Encima de la lista del Convertidor, el vídeo de más del tamaño pedido que hay en la + carpeta y qué pasa con cada parte: lo que se ofrece y en cuánto quedaría, lo que ya está en la cola, lo convertido + por reemplazar, los exportados y masters que no se ofrecen con un botón para incluirlos, lo que ahorraría poco y lo + apartado como repetido. En sus discos: 353 GB de exportados no se ofrecían (la casilla estaba apagada) y 757 GB ya + convertidos esperaban «Reemplazar». La lectura de clips no se dejaba nada: de los vídeos de más de 300 MB, solo uno + quedaba sin leer. +- **Convertir antes de ordenar.** En los pasos de la barra lateral y en Inicio, «Convertir» va antes de «Ordenar» (y + apartar lo repetido, antes de convertir). Mover y luego convertir ya funciona desde la 3.8 (la cola sigue a los + clips), pero quedaban dos cosas: un clip que se está convirtiendo no se puede mover (ffmpeg lo tiene abierto), y + si estaba en una carpeta que el plan renombraba, la carpeta se copiaba entera y luego se borraba lo que se + dejaba. Ahora lo que se convierte en ese momento se queda (y su carpeta), con el porqué en los fallos («se estaban + convirtiendo en ese momento»), y una carpeta con algo abierto ya no se copia: se queda donde está. + +Pruebas: `prueba_orden.py` 49 casos (ocho nuevos: dos a la vez y «Detener», las conversiones sueltas, la tarjeta +descomprime y su vuelta al procesador si falla, lo que ya no compensa, cerrar para todo y continuar, rehacer lo que +falta, y no mover lo que se convierte ni copiar una carpeta con algo abierto); `prueba_funcional.py` 33 de 33 (la +conversión de verdad ya descomprime con la tarjeta), `probar.py` 19 y `prueba_ventana.py` 18 (cerrar con trabajo en +marcha lo para todo). 3.8.1, convertir todo lo que ahorre un 30 %. Munka: «quiero que si un archivo se puede optimizar así sea un 30 % se convierta, la idea es aprovechar lo máximo el espacio». diff --git a/actualizador.py b/actualizador.py index eacd2f6..2c7f438 100644 --- a/actualizador.py +++ b/actualizador.py @@ -122,9 +122,10 @@ def _sha256(ruta): return h.hexdigest() -def tarea_en_marcha(): - """Texto de la tarea que impide actualizar ahora, o '' si se puede.""" - if O.COLA.get("activa"): +def tarea_en_marcha(sin_cola=False): + """Texto de la tarea que está en marcha, o ''. Para actualizar (`sin_cola`), la cola no cuenta: se detiene, y + sigue sola al volver a abrirse (ver O.detener_todo).""" + if O.COLA.get("activa") and not sin_cola: return "hay una conversión en marcha" if O.RESPALDO.get("estado") == "corriendo": return "hay una copia de respaldo en marcha" @@ -173,7 +174,7 @@ def _descargar_y_aplicar(relanzar_args): ESTADO["estado"] = "error" ESTADO["error"] = "la actualización automática solo funciona en el paquete (OrganizadorLabstream.exe); desde el código se actualiza con git" return False - ocupado = tarea_en_marcha() + ocupado = tarea_en_marcha(sin_cola=True) if ocupado: ESTADO["estado"] = "error" ESTADO["error"] = f"no se actualiza ahora: {ocupado}. Espera a que termine." @@ -270,6 +271,8 @@ def _descargar_y_aplicar(relanzar_args): f"rmdir /s /q \"{_bat(carpeta)}\" >nul 2>&1\r\n" "timeout /t 2 /nobreak >nul\r\n" "(goto) 2>nul & del \"%~f0\"\r\n") + ESTADO["estado"] = "deteniendo" + O.detener_todo(seguir=True) # la conversión que va se corta y vuelve a la cola; sigue sola al volver ESTADO["estado"] = "reiniciando" ESTADO["aplicando"] = True # con su PROPIA consola: sin ella, timeout/tasklist se quedan colgados y la copia nunca ocurre diff --git a/organizador.py b/organizador.py index 28d4ca0..9c2b163 100644 --- a/organizador.py +++ b/organizador.py @@ -2443,6 +2443,12 @@ def mover(origen, destino, lote, movidos): except OSError: # entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen if os.path.isdir(origen): + # Una carpeta en la misma unidad que no se deja renombrar tiene algo abierto (un clip que se está + # convirtiendo, un proyecto de Resolve). Antes se copiaba entera y luego se borraba lo que se dejaba: + # media carpeta duplicada y el resto a medias. Ahora se queda donde está. + if os.path.splitdrive(origen)[0].lower() == os.path.splitdrive(destino)[0].lower(): + raise OSError(f"«{os.path.basename(origen)}» tiene algo en uso dentro (¿un clip que se está convirtiendo, o abierto " + "en Resolve?): la carpeta se queda donde está") shutil.move(origen, destino) else: shutil.copy2(origen, destino) @@ -2473,6 +2479,8 @@ def _motivo_fallo(ex): w = getattr(ex, "winerror", None) if isinstance(ex, FileExistsError) or "ya existe" in t: return "existe" + if "se está convirtiendo" in t: + return "convirtiendo" if "en uso" in t or w in (32, 33): return "en_uso" if isinstance(ex, PermissionError) or w == 5 or "acceso denegado" in t: @@ -2510,6 +2518,16 @@ def aplicar(raiz, filas): con.close() en_plan = {os.path.normpath(x).lower() for f in filas for x in (f.get("archivos") or ())} marcas = [os.sep + p + os.sep for p in R.PAPELERAS] + # lo que se está convirtiendo ahora mismo no se puede mover (ffmpeg lo tiene abierto), ni la carpeta que lo + # tiene: se queda, y se dice por qué. Lo que solo espera en la cola sí se mueve (la cola lo sigue). + con = db() + convirtiendo = [os.path.normpath(r[0]).lower() for r in con.execute("SELECT ruta FROM trabajos WHERE estado = 'corriendo'")] + con.close() + en_conversion = set(convirtiendo) + + def con_conversion(carpeta): + pre = os.path.normpath(carpeta).lower() + os.sep + return any(c.startswith(pre) for c in convirtiendo) fallos = 0 APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0, unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0) @@ -2532,6 +2550,8 @@ def aplicar(raiz, filas): if not os.path.exists(a): continue try: + if os.path.normpath(a).lower() in en_conversion: + raise OSError(f"«{os.path.basename(a)}» se está convirtiendo ahora mismo: se queda hasta que acabe") antes = len(movidos) mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos) llego = movidos[-1][1] if len(movidos) > antes else "" @@ -2551,6 +2571,8 @@ def aplicar(raiz, filas): hechas += 1 else: try: + if con_conversion(f["origen"]) or os.path.normpath(f["origen"]).lower() in en_conversion: + raise OSError(f"«{os.path.basename(f['origen'])}» tiene un clip que se está convirtiendo ahora mismo: se queda hasta que acabe") if os.path.exists(f["origen"]): mover(f["origen"], f["destino"], lote, movidos) hechas += 1 @@ -4115,6 +4137,7 @@ def correr_ffmpeg(t): except OSError as ex: set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}") return + _HIJOS.add(p) ultimo = 0 for linea in p.stdout: if COLA["cancelar"]: @@ -4131,9 +4154,10 @@ def correr_ffmpeg(t): pass err = p.stderr.read() p.wait() + _HIJOS.discard(p) if COLA["cancelar"]: - set_trabajo(t["id"], estado="cancelado", pct=0) _quitar_parcial(tmp) + _devolver_a_la_cola(t["id"]) return if p.returncode == 0 and os.path.exists(tmp): break @@ -4204,6 +4228,7 @@ def correr_braw(t): errores.close() set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}") return + _HIJOS.add(p) def vigia(): # Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se @@ -4244,9 +4269,10 @@ def correr_braw(t): os.remove(resto) except OSError: pass + _HIJOS.discard(p) if COLA["cancelar"] or fin.get("estado") == "cancelado": - set_trabajo(t["id"], estado="cancelado", pct=0) _quitar_parcial(tmp) + _devolver_a_la_cola(t["id"]) return if fin.get("estado") != "listo" or not os.path.exists(tmp): set_trabajo(t["id"], estado="error", @@ -4328,9 +4354,10 @@ def correr_resolve(trabajos): con = db() for t in trabajos: r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone() - if r and r["estado"] == "corriendo": - set_trabajo(t["id"], estado="cancelado" if COLA["cancelar"] else "error", - log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo") + if r and r["estado"] == "corriendo" and COLA["cancelar"]: + _devolver_a_la_cola(t["id"]) + elif r and r["estado"] == "corriendo": + set_trabajo(t["id"], estado="error", log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo") con.close() @@ -4699,6 +4726,108 @@ def calidad_de(id_, segundos=10, ancho=1920): return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)} +_HIJOS = set() # las conversiones en marcha (ffmpeg, el lector de BRAW): se cierran al cerrar o actualizar + + +def _parcial_de(t): + fm = FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"]) + return (t["salida"] or "") + ".parcial" + fm["ext"] if t["salida"] else "" + + +def _devolver_a_la_cola(id_, motivo="se detuvo a mitad"): + """Lo que se cortó a medias (Detener, cerrar el programa, actualizar) vuelve a esperar en su sitio, delante de + los demás: al continuar se hace entero otra vez (un MP4 a medio escribir no se puede seguir), y su archivo a + medias se borra. Hasta la 3.9 quedaba «cancelado» y «Reanudar» no lo retomaba: el 14-oct-2026, dos BRAW + cortados al actualizar se quedaron fuera de la cola.""" + con = db() + t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() + con.close() + if not t: + return + parcial = _parcial_de(dict(t)) + if parcial: + _quitar_parcial(parcial) + set_trabajo(id_, estado="pendiente", pct=0, log=f"{motivo} (iba al {round(t['pct'] or 0)} %): al continuar se hace entero otra vez") + + +def _ruta_seguir(): + return os.path.join(DATOS, "cola_seguir.json") + + +def detener_todo(seguir=False, espera=30): + """Para todo lo que está en marcha, para cerrar el programa o actualizarlo. Munka, 14-oct-2026: «que al cerrar + cierre todos los procesos, hasta las colas que faltan y los que quedan a mitad». La cola se detiene y lo que iba + vuelve a esperar, la lectura de clips, el respaldo y lo que se trae de una tarjeta se cortan; un «Aplicar» se + deja acabar (son segundos, y a medias dejaría el plan a medias). Lo que no se cierre por las buenas se cierra + a la fuerza, con lo que haya lanzado. Con `seguir` (al actualizar), la cola sigue sola al volver a abrirse.""" + habia_cola = bool(COLA["activa"] or COLA["actuales"]) + if habia_cola: + COLA["cancelar"] = True + COLA["activa"] = False + for a in list(ANALISIS.values()): + if a and a.get("estado") == "corriendo": + a["cancelar"] = True + if RESPALDO.get("estado") == "corriendo": + RESPALDO["cancelar"] = True + if TRAER.get("estado") in ("copiando", "ordenando"): + TRAER["cancelar"] = True + + def ocupado(): + hilo = COLA.get("hilo") + return bool((hilo and hilo.is_alive()) or (APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"])) + limite = time.time() + espera + while time.time() < limite and ocupado(): + time.sleep(0.3) + for p in list(_HIJOS): # lo que no se cerró por las buenas + if p.poll() is None: + _matar_arbol(p.pid) + _HIJOS.discard(p) + con = db() + cortados = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'corriendo'")] + con.close() + for i in cortados: + _devolver_a_la_cola(i) + con = db() + pendientes = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0] + con.close() + if seguir and habia_cola and pendientes: + guardar_json(_ruta_seguir(), {"ts": time.time(), "pendientes": pendientes}) + return {"cola": habia_cola, "pendientes": pendientes} + + +def _seguir_al_abrir(): + """Si la cola se detuvo para actualizar, sigue sola al volver a abrirse, por donde iba.""" + ruta = _ruta_seguir() + if not os.path.exists(ruta): + return False + try: + os.remove(ruta) + except OSError: + pass + return iniciar_cola() + + +def rehacer_perdidos(): + """Los convertidos que ya no están (alguien los borró o los movió fuera de la app) se vuelven a hacer: el trabajo + vuelve a la cola. Solo en carpetas conectadas (si no, que no esté no dice nada), y después de reengancharlos por + si solo se movieron. Munka: «que al continuar … vuelva a generar los videos que hagan falta».""" + con = db() + ts = [dict(r) for r in con.execute("SELECT id, raiz, salida FROM trabajos WHERE estado IN ('listo','verificado') AND salida IS NOT NULL")] + con.close() + conectadas = {} + ts = [t for t in ts if conectadas.setdefault(t["raiz"], os.path.isdir(t["raiz"] or ""))] + if not ts: + return 0 + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=16) as ex: + estan = list(ex.map(lambda t: os.path.isfile(t["salida"]), ts)) + perdidos = [t for t, esta in zip(ts, estan) if not esta] + for t in perdidos: + set_trabajo(t["id"], estado="pendiente", pct=0, bytes_out=None, nota=None, nota_min=None, + log="el convertido ya no estaba: se vuelve a hacer") + return len(perdidos) + + def _matar_arbol(pid): """Cierra un proceso y lo que lanzó (el lector de BRAW lanza su propio ffmpeg).""" try: @@ -4772,20 +4901,19 @@ def cerrar_conversores_sueltos(): def recuperar_huerfanas(): - """Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de - una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso - Convertir decía «nada» (visto el 2026-09-15: 5 clips de V:\\Galeano al 18-89 % desde el día 10). - Vuelven a «pendiente» con el motivo en el log; Reanudar los convierte desde cero.""" + """Al arrancar y al continuar, vuelve a esperar en su sitio lo que quedó a medias: lo que estaba «corriendo» sin + una cola viva (el programa se cerró a mitad), lo «cancelado» por un «Detener» de antes de la 3.9 y lo que falló + por un corte (un «fallo inesperado»: el 14-oct-2026, el lector de BRAW chocó con su propio archivo de una + conversión anterior que había quedado colgada). Su archivo a medias se borra; al continuar se hace entero.""" if COLA["hilo"] and COLA["hilo"].is_alive(): return 0 con = db() - with con: - filas = con.execute("SELECT id, pct FROM trabajos WHERE estado = 'corriendo'").fetchall() - for f in filas: - con.execute("UPDATE trabajos SET estado = 'pendiente', pct = 0, log = ? WHERE id = ?", - (f"se interrumpió al cerrar OrganiLAB (iba al {round(f['pct'] or 0)} %); pulsa Reanudar para convertirlo de nuevo", f["id"])) + ids = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado IN ('corriendo','cancelado') " + "OR (estado = 'error' AND log LIKE 'fallo inesperado%')")] con.close() - return len(filas) + for i in ids: + _devolver_a_la_cola(i, "se interrumpió") + return len(ids) def braws_al_lector(): @@ -4834,6 +4962,7 @@ def iniciar_cola(): recuperar_huerfanas() try: braws_al_lector() + rehacer_perdidos() except Exception: # noqa pass COLA["activa"] = True @@ -6920,8 +7049,10 @@ def _nombre_tipo(t): # el orden de la lista de Inicio: lo hecho se queda en su sitio, no se va al final ni desaparece -_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "fechas", "resto", "banco", "vacias", "stock", - "repetidos", "convertir", "juntar", "respaldar"] +# Convertir va antes de ordenar (Munka, 14-oct-2026: «colocaría convertir antes de mover los videos»), y lo repetido +# se aparta antes de convertir: no se convierte lo que se va a borrar. +_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "repetidos", "convertir", "fechas", "resto", "banco", + "vacias", "stock", "juntar", "respaldar"] def _historial_carpeta(raiz): @@ -8453,6 +8584,7 @@ def _ventana(url): base=BASE, marca=_marca(), relanzar=instalar.orden(BASE), depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR"))) v.al_cerrar, v.al_soltar = en_marcha, _al_soltar + v.al_detener = lambda: detener_todo(seguir=False) v.al_abrir = lambda: _recien_abierta(v) fin = threading.Event() threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start() @@ -8546,10 +8678,10 @@ def main(): except OSError: pass huerfanas = recuperar_huerfanas() - threading.Thread(target=lambda: (cerrar_conversores_sueltos(), reubicar_trabajos(), repasar_verificados(), braws_al_lector()), - daemon=True).start() + threading.Thread(target=lambda: (cerrar_conversores_sueltos(), reubicar_trabajos(), repasar_verificados(), braws_al_lector(), + rehacer_perdidos(), _seguir_al_abrir()), daemon=True).start() if huerfanas: - print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") + print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Continuar).") try: srv = ServidorLocal(("127.0.0.1", PUERTO), Manejador) except OSError: diff --git a/prueba_orden.py b/prueba_orden.py index d93274d..4c65bcc 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -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.""" diff --git a/prueba_ventana.py b/prueba_ventana.py index fe7211d..878b588 100644 --- a/prueba_ventana.py +++ b/prueba_ventana.py @@ -278,7 +278,8 @@ def desde_el_codigo(tmp): anota("abrirlo otra vez no abre una segunda copia", p.returncode == 0 and len(V._ventanas(O._marca())) == 1, f"código {p.returncode} · {len(V._ventanas(O._marca()))} ventana(s)") - # cerrar con trabajo en marcha: avisa, se oculta, vuelve si se abre otra vez y se cierra sola al acabar + # cerrar con trabajo en marcha: avisa, se quita de la vista y lo para todo (un movimiento de archivos se deja + # acabar); si se abre otra vez mientras tanto, vuelve; y al acabar de parar se cierra sola O.APLICANDO.update(total=10, hechas=3, raiz="ensayo") U.PostMessageW(h, WM_CLOSE, 0, 0) d = espera(lambda: dialogo_de(h), 8) @@ -286,7 +287,7 @@ def desde_el_codigo(tmp): anota("al cerrar con trabajo en marcha, avisa de qué está haciendo", "moviendo archivos" in aviso, aviso[:90]) if d: U.PostMessageW(d, WM_COMMAND, 1, 0) - anota("la ventana se quita de la vista y el programa sigue", espera(lambda: v.oculta and not U.IsWindowVisible(h), 8)) + anota("la ventana se quita de la vista mientras se para todo", espera(lambda: v.oculta and not U.IsWindowVisible(h), 8)) otra_copia() anota("abrirlo otra vez devuelve esa misma ventana", espera(lambda: not v.oculta and U.IsWindowVisible(h), 8)) U.PostMessageW(h, WM_CLOSE, 0, 0) @@ -294,7 +295,7 @@ def desde_el_codigo(tmp): if d: U.PostMessageW(d, WM_COMMAND, 1, 0) espera(lambda: v.oculta, 8) - O.APLICANDO.update(hechas=10) # el trabajo termina: el programa tiene que cerrarse solo + O.APLICANDO.update(hechas=10) # el movimiento termina: con todo parado, el programa se cierra solo fin, forzado = threading.Event(), [] diff --git a/static/index.html b/static/index.html index 6923b23..90573ac 100644 --- a/static/index.html +++ b/static/index.html @@ -446,6 +446,9 @@ .cmp .marco.con .lupa{display:block} /* en vertical (móvil, cámara girada): los dos cuadros a lo alto, uno junto al otro, y la nota y lo demás en una columna a su derecha. Antes el marco era siempre apaisado y el clip quedaba como una tira en medio */ + .grandes{border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:12.5px;display:flex;flex-wrap:wrap;gap:6px 18px;align-items:baseline} + .grandes b{font-size:13px}.grandes .t{color:var(--mut);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;flex-basis:100%} + .grandes button{font-size:12px;padding:2px 8px} .cmpcuerpo.vert{display:grid;grid-template-columns:auto minmax(260px,1fr);gap:22px;align-items:start} .cmpcuerpo.vert .cmp{grid-template-columns:auto auto;justify-content:start} .cmpcuerpo.vert .cmp .lado{min-width:auto} @@ -716,6 +719,7 @@
+${s.length} clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a ${esc(fm.nombre)}, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.
El original no se toca. Cuando terminen y estén verificados, decides si reemplazar.
`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, ahorro_minimo: ahorroPedido(), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola o su salida ya existe (el mismo clip en MP4 y MOV, o una conversión anterior).`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; }; const EST_TXT = {pendiente:'en espera', corriendo:'convirtiendo', listo:'convertido', verificado:'verificado', reemplazado:'reemplazado', error:'falló', cancelado:'cancelado', descartado:'no compensa'}; @@ -1970,7 +1999,7 @@ async function cargarCola(){ const n = k => ts.filter(t => t.estado === k).length; const pend = n('pendiente'), listos = n('listo'), verif = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores')).length, err = n('error') + n('cancelado'); const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}${n('descartado') ? ` · ${n('descartado')} que no compensan` : ''}` : 'sin actividad'; - const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar'; + const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Continuar'; $('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado','descartado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none'; $('#btnQuitarEspera').style.display = (!d.cola.activa && pend) ? '' : 'none'; bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar'; @@ -2043,7 +2072,7 @@ async function cargarCola(){ const ahora = corrs.length > 1 ? `a la vez: ${corrs.map(x => `${esc((x.ruta || '').split(/[\\/]/).pop())} ${Math.round(x.pct || 0)} %${restoDe(x)}`).join(' · ')} · ${eta ? eta.en_espera : pend} en espera` : ''; mo = `${esc(a.disponible.notes || '')}
` + (a.error ? `${esc(a.error)}
` : ''); - $('#btnActualizar').onclick = () => accion($('#btnActualizar'), async () => { if (huella(ESTADO).cola || huella(ESTADO).resp || huella(ESTADO).asis) throw new ApiError('Hay una tarea en marcha. Espera a que termine antes de actualizar.'); const r = await api('/api/actualizacion/instalar', {}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar la actualización'); seguirActualizacion(); }); + caja.innerHTML = `${esc(a.disponible.notes || '')}
` + (a.error ? `${esc(a.error)}
` : ''); + $('#btnActualizar').onclick = () => accion($('#btnActualizar'), async () => { if (huella(ESTADO).resp || huella(ESTADO).asis) throw new ApiError('Hay una copia o el asistente en marcha. Espera a que termine antes de actualizar.'); const r = await api('/api/actualizacion/instalar', {}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar la actualización'); seguirActualizacion(); }); } async function seguirActualizacion(){ ACT_SIGUIENDO = true; const caja = $('#actualizacionCaja'); caja.style.display = ''; irA('carpeta'); diff --git a/ventana.py b/ventana.py index 2348400..9200939 100644 --- a/ventana.py +++ b/ventana.py @@ -391,7 +391,7 @@ class Ventana: self.url, self.titulo, self.icono, self.perfil, self.memoria = url, titulo, icono, perfil, memoria self.base, self.marca, self.tamano, self.minimo, self.fondo = base, marca, tamano, minimo, fondo self.relanzar, self.depurar = relanzar, depurar - self.al_cerrar = self.al_soltar = self.al_abrir = None + self.al_cerrar = self.al_soltar = self.al_abrir = self.al_detener = None self.hwnd = None self.oculta = False # cerrada a la vista, pero viva hasta que termine el trabajo self.lista = False # la interfaz está cargada y a la vista @@ -500,9 +500,22 @@ class Ventana: if not que: return self._destruir() u = _dll().u - r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras la ventana, el trabajo sigue hasta " - "terminar y después el programa se cierra solo. Para verlo, vuelve a abrir OrganiLAB.", - self.titulo, MB_OKCANCEL | MB_ICONINFORMATION) + if not self.al_detener: + r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras la ventana, el trabajo sigue hasta " + "terminar y después el programa se cierra solo. Para verlo, vuelve a abrir OrganiLAB.", + self.titulo, MB_OKCANCEL | MB_ICONINFORMATION) + if r != IDOK: + return + self._recordar_sitio() + self.oculta = True + self.progreso(None) + u.ShowWindow(self.hwnd, SW_HIDE) + return + # Cerrar para todo (Munka, 14-oct-2026): la conversión que va se corta y vuelve a la cola, y la próxima vez + # «Continuar» sigue por donde iba. Antes el trabajo seguía oculto y, al actualizar, quedaban procesos sueltos. + r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras, se detiene todo. Lo que iba a medias " + "vuelve a la cola, y la próxima vez «Continuar» sigue por donde iba (lo cortado se hace entero otra vez).", + self.titulo, MB_OKCANCEL | MB_ICONQUESTION) if r != IDOK: return self._recordar_sitio() @@ -510,6 +523,16 @@ class Ventana: self.progreso(None) u.ShowWindow(self.hwnd, SW_HIDE) + def parar(): + try: + self.al_detener() + except Exception as ex: # noqa + _log("al_detener falló:", repr(ex)) + finally: + if self.oculta: # si mientras se paraba la volvieron a abrir, se queda abierta (ya sin nada en marcha) + self.cerrar() + threading.Thread(target=parar, daemon=True).start() + def _volver(self): """La abrieron otra vez mientras trabajaba oculta: vuelve a la vista.""" u = _dll().u