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

@@ -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: