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:
38
README.md
38
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».
|
||||
|
||||
@@ -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
|
||||
|
||||
172
organizador.py
172
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:
|
||||
|
||||
136
prueba_orden.py
136
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."""
|
||||
|
||||
@@ -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(), []
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<button class="lnk" id="btnQuitarEspera" style="display:none">Quitar los que esperan</button>
|
||||
</div>
|
||||
<div id="anaEstado" class="mut" style="margin:-6px 0 10px"></div>
|
||||
<div id="convGrandes" class="grandes" style="display:none"></div>
|
||||
<div id="tanda"></div>
|
||||
<details class="mas" id="colaAjustes" style="margin-bottom:12px"><summary>Cuándo trabaja la cola y qué mete sola</summary><div class="ajcola" id="colaAjustesCuerpo"></div></details>
|
||||
<div class="box" id="convCaja"><div id="convLista"><span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span></div></div>
|
||||
@@ -1401,6 +1405,7 @@ function pasosDe(C, r){
|
||||
: verif ? {e:'listo', t:'Convertir', d:`${verif} verificado${verif > 1 ? 's' : ''} por reemplazar`, corto: `${verif} por reemplazar`}
|
||||
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya convertido${reemp > 1 ? 's' : ''}`, corto: `${reemp} hechos`}
|
||||
: {e:'pend', t:'Convertir', d:`${gb(cv.camara_bytes)} de cámara${cv.raw_bytes ? ' · ' + gb(cv.raw_bytes) + ' BRAW' : ''}`, corto: gb(cv.camara_bytes)});
|
||||
{ const io = P.findIndex(x => x.t === 'Ordenar'), ic = P.findIndex(x => x.t === 'Convertir'); if (io >= 0 && ic > io) P.splice(io, 0, P.splice(ic, 1)[0]); } // convertir antes de mover
|
||||
const rp = C.respaldo || {};
|
||||
P.push(!esc ? {e:'bloq', t:'Respaldar', d:'Después de escanear'} : rp.estado === 'corriendo' ? {e:'curso', t:'Respaldar', d:'Copiando…', corto: 'copiando'}
|
||||
: rp.plan ? {e:'listo', t:'Respaldar', d:`${num(rp.plan.n)} archivos · ${gb(rp.plan.bytes)} listos para copiar`, corto: gb(rp.plan.bytes)}
|
||||
@@ -1813,6 +1818,7 @@ const MOTIVO_FALLO = {
|
||||
en_uso: ['estaban abiertos en otro programa', 'Cierra Resolve, Premiere o la ventana del Explorador que los tenga abiertos, pulsa «Calcular de nuevo» y aplica: solo intentará lo que faltó.'],
|
||||
permiso: ['Windows no dio permiso para moverlos', 'Suele ser una carpeta del NAS de solo lectura o de otro usuario. Revisa los permisos de esa carpeta compartida.'],
|
||||
no_esta: ['ya no estaban donde decía el plan', 'Alguien los movió o los borró después de calcular. Pulsa «Calcular de nuevo».'],
|
||||
convirtiendo: ['se estaban convirtiendo en ese momento', 'Un clip que se convierte no se puede mover, ni la carpeta que lo tiene: se quedaron donde estaban. Cuando acabe la cola, pulsa «Calcular de nuevo» y aplica: se moverán junto con su convertido.'],
|
||||
copia: ['la copia entre discos no quedó completa', 'El original no se tocó. Vuelve a aplicar; si se repite, mira el espacio libre y la conexión con el NAS.'],
|
||||
red: ['se cortó la conexión con el disco de red', 'Comprueba que la unidad sigue conectada y vuelve a aplicar: solo intentará lo que faltó.'],
|
||||
otro: ['por otra razón', 'Mira la lista con «ver cuáles».'],
|
||||
@@ -1954,9 +1960,32 @@ async function cargarCandidatos(){
|
||||
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}&ahorro=${ahorroPedido()}`;
|
||||
const d = await api('/api/optimizar/candidatos?' + q); CAND = d.candidatos.map(c => ({...c, sel: !c.excluido && !c.en_cola}));
|
||||
$('#anaEstado').textContent = '';
|
||||
pintarGrandes();
|
||||
if (!CAND.length) aviso('No se encontraron clips con esos criterios. Prueba a cambiar los meses o el tamaño mínimo.', '');
|
||||
await cargarCola();
|
||||
}
|
||||
// Lo que pesa de la carpeta, de un vistazo: cuánto vídeo grande hay y qué pasa con cada parte. Munka, 14-oct-2026:
|
||||
// «que identifique bien el material y haga la búsqueda de grandes archivos de video». En sus discos, 353 GB de
|
||||
// exportados y masters no se ofrecían porque la casilla estaba apagada, y 757 GB ya convertidos esperaban «Reemplazar».
|
||||
function pintarGrandes(){
|
||||
const caja = $('#convGrandes'); if (!caja) return;
|
||||
if (!CAND.length) { caja.style.display = 'none'; return; }
|
||||
const g = {}; const suma = (k, c) => { const x = g[k] = g[k] || {n: 0, b: 0, e: 0}; x.n++; x.b += c.bytes || 0; x.e += c.estimado || 0; };
|
||||
CAND.forEach(c => { const m = c.excluido || ''; suma(c.en_cola ? (['listo', 'verificado'].includes(c.en_cola) ? 'hechos' : 'cola') : !m ? 'ofrece' : m.startsWith('es un exportado') ? 'export' : m.startsWith('está apartado') ? 'repes' : m.startsWith('convertido ahorraría') ? 'poco' : m.startsWith('ya es una conversión') ? 'ya' : 'otros', c); });
|
||||
const tot = CAND.reduce((s, c) => s + (c.bytes || 0), 0), v = k => g[k] || {n: 0, b: 0, e: 0};
|
||||
const trozo = (k, txt, extra = '') => v(k).n ? `<span><b>${num(v(k).n)}</b> ${txt} · ${gb(v(k).b)}${extra}</span>` : '';
|
||||
caja.innerHTML = `<span class="t">Vídeo de más de ${+$('#optMin').value || 0} MB en esta carpeta: ${num(CAND.length)} archivos · ${gb(tot)}</span>`
|
||||
+ trozo('ofrece', 'se ofrecen', ` → ≈ ${gb(v('ofrece').e)}`)
|
||||
+ trozo('cola', 'en la cola')
|
||||
+ trozo('hechos', 'ya convertidos, por reemplazar', ' (se liberan al reemplazar)')
|
||||
+ trozo('export', 'exportados y masters sin incluir', ` → ≈ ${gb(v('export').e)} <button id="grInclExp">incluirlos</button>`)
|
||||
+ trozo('poco', 'ahorrarían poco')
|
||||
+ trozo('repes', 'apartados como repetidos (para borrar)')
|
||||
+ trozo('ya', 'convertidos por el programa')
|
||||
+ trozo('otros', 'por otros motivos (alfa, stock, recientes…)');
|
||||
caja.style.display = '';
|
||||
const b = $('#grInclExp'); if (b) b.onclick = () => { $('#optExport').checked = true; cargarCandidatos().catch(e => aviso(e.message, 'bad')); };
|
||||
}
|
||||
function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.filter(c => !c.excluido && !c.en_cola); bloquear('btnEncolar', validos.length ? (s.length ? '' : 'x') : 'x'); $('#btnEncolar').textContent = s.length ? `Convertir ${s.length} marcado${s.length > 1 ? 's' : ''}` : 'Convertir'; }
|
||||
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => 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 => `<b>${esc((x.ruta || '').split(/[\\/]/).pop())}</b> ${Math.round(x.pct || 0)} %${restoDe(x)}`).join(' · ')} · ${eta ? eta.en_espera : pend} en espera` : '';
|
||||
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${corrs.length > 1 ? `${Math.min(hechosT + 1, total)} y ${Math.min(hechosT + 2, total)}` : Math.min(hechosT + 1, total)} de ${total}</b><span class="mut">${eta && eta.seg ? `quedan unas <b>${tiempoLargo(eta.seg)}</b> · termina hacia las <b>${hora(eta.termina)}</b>${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div><div class="bar grande"><i style="width:${pctTot}%"></i></div><div class="l3 mut">${ahora || (corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${restoDe(corr)} de este clip · ${eta ? eta.en_espera : pend} en espera` : 'empezando…')}</div></div>`;
|
||||
} else if (pend) {
|
||||
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">lo que ya iba se retoma desde cero</span><span class="sep"></span><button class="p grande" id="moReanudar">Reanudar la cola</button></div></div>`;
|
||||
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">sigue por donde iba; lo que quedó a medias se hace entero otra vez</span><span class="sep"></span><button class="p grande" id="moReanudar">Continuar la cola</button></div></div>`;
|
||||
} else if (vers.length) {
|
||||
const bIn = vers.reduce((s, t) => s + (t.bytes_in || 0), 0), bOut = vers.reduce((s, t) => s + (t.bytes_out || 0), 0);
|
||||
mo = `<div class="momento listo"><div class="l1"><span class="ico">✓</span><span><b>${vers.length} clip${vers.length > 1 ? 's' : ''} convertido${vers.length > 1 ? 's' : ''} y verificado${vers.length > 1 ? 's' : ''}</b> · ${gb(bIn)} → ${gb(bOut)}${notaMedia ? ` · nota media ${notaMedia} / 100` : ''}</span><span class="sep"></span><button class="lnk" id="moComparar">comparar unos cuantos</button><button class="ok grande" id="moReemplazar">Reemplazar ${vers.length > 1 ? 'los ' + vers.length + ' originales' : 'el original'} · libera ${gb(Math.max(0, bIn - bOut))}</button></div><div class="l3 mut">Los originales van a _Para borrar/originales, no se borran, y se deshace desde Historial. Si quieres verlo antes, «comparar» enseña el mismo cuadro en los dos y mide la calidad.</div></div>`;
|
||||
@@ -2686,8 +2715,8 @@ function pintarActualizacion(){
|
||||
if (a.aplicando || a.estado === 'descargando' || a.estado === 'verificando' || a.estado === 'reiniciando') { if (!ACT_SIGUIENDO) seguirActualizacion(); return; }
|
||||
if (!a.disponible) { caja.style.display = 'none'; return; }
|
||||
caja.style.display = ''; caja.classList.add('actualizacion');
|
||||
caja.innerHTML = `<h3>Hay una versión nueva: ${esc(a.disponible.version)} <span class="mut">(tienes la ${esc(ESTADO.version)})</span></h3><p class="mut">${esc(a.disponible.notes || '')}</p><div class="toolbar"><button id="btnActualizar" class="p">Actualizar ahora</button><span class="mut">${(a.disponible.size / 1e6).toFixed(0)} MB · el programa se cierra y vuelve a abrirse solo; tus datos no se tocan. No lo hagas con una conversión o copia en marcha.</span></div>` + (a.error ? `<p class="bad">${esc(a.error)}</p>` : '');
|
||||
$('#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 = `<h3>Hay una versión nueva: ${esc(a.disponible.version)} <span class="mut">(tienes la ${esc(ESTADO.version)})</span></h3><p class="mut">${esc(a.disponible.notes || '')}</p><div class="toolbar"><button id="btnActualizar" class="p">Actualizar ahora</button><span class="mut">${(a.disponible.size / 1e6).toFixed(0)} MB · el programa se cierra y vuelve a abrirse solo; tus datos no se tocan. Si está convirtiendo, la cola se detiene y sigue sola al volver (el clip que iba se hace entero otra vez).</span></div>` + (a.error ? `<p class="bad">${esc(a.error)}</p>` : '');
|
||||
$('#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');
|
||||
|
||||
25
ventana.py
25
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,6 +500,7 @@ class Ventana:
|
||||
if not que:
|
||||
return self._destruir()
|
||||
u = _dll().u
|
||||
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)
|
||||
@@ -509,6 +510,28 @@ class Ventana:
|
||||
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()
|
||||
self.oculta = True
|
||||
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."""
|
||||
|
||||
Referencia en una nueva incidencia
Block a user