diff --git a/README.md b/README.md index 2919bea..8fa20d0 100644 --- a/README.md +++ b/README.md @@ -912,7 +912,25 @@ que rechazar, versiones con ffprobe simulado, y la pasada automática con sus fr 3.8.0, lo que faltaba. Munka, después de la 3.7.0: «al hacer un cambio en el plan me debería dar un chequeo para saber que ya se hizo», «construye las mejoras que planteas» y, aplicando el plan de Vía Sumapaz, 753 -movimientos que no se pudieron y «database is locked» al buscar clips para convertir («lo más importante»). +movimientos que no se pudieron y «database is locked» al buscar clips para convertir («lo más importante»). Y +antes de publicarla: «moví unos archivos y luego no se pudieron volver a renderizar, y al volver a escanear no +los volvió a encontrar», y el comparador, que en vertical no se veía bien. + +- **La cola de conversión sigue a los clips.** Apuntaba a cada clip por su ruta y nadie la avisaba al moverlo: + en Línea Gene el plan llevó «Material Sony» a 00_Grabacion, 138 conversiones en espera fallaron buscando cada + clip donde ya no estaba, los 11 verificados no se podían reemplazar (y su «_10bit» se quedó en la carpeta + vieja, porque se convirtió después de escanear y el plan no lo conocía), y «Buscar clips» volvía a ofrecer + esos mismos originales como si no se hubieran convertido. Ahora, cuando la app mueve algo (el plan, Deshacer, + renombrar en el mapa) los trabajos se van con su clip y el convertido sale junto a él en el sitio nuevo; el + convertido que está junto a su original lo acompaña aunque el plan no lo conociera. Si los movió otra cosa + (el Explorador, la 3.7), al volver a escanear la cola los busca por nombre y peso y los que habían fallado + por eso vuelven a esperar turno; justo antes de convertir cada clip se comprueba que esté, y si no, falla + diciendo qué hacer. «Reemplazar» lleva antes el convertido junto a su original si quedaron separados. Lo que + la app aparta a _Para borrar o a Duplicados no se sigue: eso no se convierte. Y volver a escanear conserva + la lectura de un clip que solo cambió de carpeta (mismo nombre, peso y fecha): no hay que leerlo otra vez. +- **Comparar, en vertical**: el marco toma la forma del clip; uno vertical se ve a lo alto, los dos cuadros uno + junto al otro y la nota a su lado, en vez de una tira estrecha en medio de un marco apaisado. La lupa amplía + el recorte sin estirarlo (antes lo deformaba en todo lo que no era 16:9) y se ajusta al tamaño del cuadro. - **«database is locked» al buscar clips.** Después de «Aplicar» en una carpeta grande, el inventario se ponía al día con la base tomada durante más de diez minutos (en Sumapaz, 2.493 movimientos): preguntaba al disco de @@ -955,8 +973,9 @@ movimientos que no se pudieron y «database is locked» al buscar clips para con - **Compilar en el estudio**: `.github/workflows/publicar.yml` y la guía de arriba; las pruebas devuelven error si algo falla, y `verificar_publicado.py` está en el repositorio. -Pruebas: `prueba_orden.py` 38 casos (cuatro nuevos: «Alta» y «Baja», el reloj de una cámara y buscar, el banco -por fecha, otros discos); `prueba_funcional.py` 33 de 33 (buscar y «Fechas raras» por la interfaz del servidor); +Pruebas: `prueba_orden.py` 39 casos (cinco nuevos: «Alta» y «Baja», el reloj de una cámara y buscar, el banco +por fecha, otros discos, y la cola que sigue al clip: el plan, Deshacer, el Explorador y volver a escanear, justo +antes de convertir, y reemplazar con el convertido en otra carpeta); `prueba_funcional.py` 33 de 33 (buscar y «Fechas raras» por la interfaz del servidor); `probar.py` 19 y `prueba_ventana.py` 18. 3.7.0, la auditoría de Vía Sumapaz. Munka: «creo que el escáner de archivos fue superficial», «no me sale la diff --git a/organizador.py b/organizador.py index 91293e8..280c543 100644 --- a/organizador.py +++ b/organizador.py @@ -644,6 +644,8 @@ def escanear(ruta): previo = {r["ruta"]: r for r in con.execute( "SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, " "pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()} + # y la de los que solo cambiaron de carpeta (movidos con el Explorador): mismo nombre, peso y fecha + por_firma = {(os.path.basename(r["ruta"]).lower(), r["bytes"]): r for r in previo.values()} con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,)) con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,)) con.executemany( @@ -651,7 +653,7 @@ def escanear(ruta): "anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", filas) for f in filas: - pv = previo.get(f[0]) + pv = previo.get(f[0]) or por_firma.get((f[3].lower(), f[5])) if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2: con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", (pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0])) @@ -676,6 +678,10 @@ def escanear(ruta): prog["estado"] = "listo" if fallos: prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})" + try: + reubicar_trabajos(raiz) # la cola encuentra los clips que se movieron desde el último escaneo + except Exception: # noqa + pass olvidar_plan(raiz, recalcular=False) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco except Exception as ex: # noqa prog["estado"] = "error" @@ -2419,6 +2425,16 @@ def aplicar(raiz, filas): m["ejemplos"].append(que) filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower())) unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas) + # el convertido que está junto a su original lo acompaña: si no, el original se iba a 00_Grabacion y su + # «_10bit» se quedaba en la carpeta vieja (el inventario aún no lo tenía si se convirtió después de escanear) + con = db() + convertidos = defaultdict(list) + for r in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('listo','verificado') AND salida IS NOT NULL"): + if os.path.dirname(r["salida"]).lower() == os.path.dirname(r["ruta"] or "").lower(): + convertidos[os.path.normpath(r["ruta"])].append(os.path.normpath(r["salida"])) + 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] 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) @@ -2441,7 +2457,12 @@ def aplicar(raiz, filas): if not os.path.exists(a): continue try: + 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 "" + for s in convertidos.get(os.path.normpath(a), ()): + if llego and s.lower() not in en_plan and not any(m in llego for m in marcas) and os.path.isfile(s): + mover(s, os.path.join(os.path.dirname(llego), os.path.basename(s)), lote, movidos) except Exception as ex: # noqa fallos_fila += 1 fallo(ex, os.path.relpath(a, raiz)) @@ -2504,7 +2525,10 @@ def actualizar_inventario(movidos): # carpeta puede aparecer a mitad de la tanda, renombrada por un movimiento anterior). tocadas = _tocadas(movidos) existe = _existen({d for d, _ in tocadas}) + cola = _cola_tras_mover(con, movidos, marcas) with con: + for id_, c in cola: + con.execute(f"UPDATE trabajos SET {', '.join(k + ' = ?' for k in c)} WHERE id = ?", (*c.values(), id_)) for de, a in movidos: en_papelera = 1 if any(m in a for m in marcas) else 0 con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, " @@ -2538,6 +2562,122 @@ def actualizar_inventario(movidos): olvidar_plan(k) +# Qué sigue la cola cuando la app mueve algo. El clip, en todo lo que aún lo usa (el «descartado» también: +# así no se vuelve a probar a la misma calidad tras moverlo). El convertido, en lo que ya salió. «corriendo» +# no: ese clip lo tiene abierto ffmpeg, y Windows no deja moverlo. Un «reemplazado» guarda en su ruta dónde +# estaba el original, para encontrarlo en _Para borrar\originales: esa no se toca. +_SIGUEN_CLIP = ("pendiente", "error", "cancelado", "listo", "verificado", "descartado") +_SIGUEN_SALIDA = ("listo", "verificado", "reemplazado") + + +def _cola_tras_mover(con, movidos, marcas): + """La cola de conversión apunta a cada clip por su ruta. Cuando el plan (o Deshacer, o apartar) lo mueve, el + trabajo se va con él: el que esperaba se convierte en el sitio nuevo y su convertido sale allí, y el ya + convertido se reemplaza donde está ahora su original. Medido el 14-oct-2026 en Línea Gene: el plan llevó + los clips de «Material Sony» a 00_Grabacion y 138 conversiones en espera fallaron buscando cada clip donde + ya no estaba; los 11 verificados no se podían reemplazar, y «Buscar clips» volvía a ofrecer esos mismos + originales como si nunca se hubieran convertido. Lo que va a la papelera de la app no se sigue: un clip + apartado no se convierte. Devuelve [(id, {columna: valor})].""" + ts = [dict(r) for r in con.execute("SELECT id, ruta, salida, estado, raiz, formato FROM trabajos WHERE estado != 'corriendo'")] + if not ts: + return [] + antes = {t["id"]: (t["ruta"], t["salida"]) for t in ts} + apartes = list(marcas) + [os.sep + R.DUPLICADOS + os.sep] # ni lo que se aparta como repetido + for de, a in movidos: + if any(m in a for m in apartes): + continue + pre = de + os.sep + for t in ts: + for k, siguen in (("ruta", _SIGUEN_CLIP), ("salida", _SIGUEN_SALIDA)): + v = t[k] or "" + if t["estado"] in siguen and (v == de or v.startswith(pre)): + t[k] = a + v[len(de):] + cambios = [] + for t in ts: + ruta0, salida0 = antes[t["id"]] + if (t["ruta"], t["salida"]) == (ruta0, salida0): + continue + c = {"ruta": t["ruta"], "salida": t["salida"]} + if t["ruta"] != ruta0: + c["raiz"] = _raiz_de(t["ruta"]) or t["raiz"] + if t["estado"] in ("pendiente", "error", "cancelado"): + # aún no hay convertido: saldrá junto al clip, en su sitio nuevo + c["salida"] = salida_para(t["ruta"], c["raiz"], t["formato"] or "h265_10bit") + cambios.append((t["id"], c)) + return cambios + + +def reubicar_trabajos(raiz=None, ids=None): + """Vuelve a enlazar con su clip los trabajos de la cola que apuntan a donde el inventario ya no lo tiene: lo + movió la 3.7 o una anterior sin avisar a la cola, o alguien con el Explorador y luego se volvió a escanear. + Se busca el mismo nombre y el mismo peso en el inventario de la misma carpeta de cliente, fuera de la + papelera, y solo si sale uno. El que había fallado por eso vuelve a la cola, con su convertido junto al clip + en el sitio nuevo; el que ya se encoló de nuevo allí se quita (sobraba). Igual con el convertido de los ya + hechos, si se movió. Devuelve cuántos se enlazaron.""" + con = db() + try: + escaneadas = {(r["raiz"] or "").lower() for r in con.execute("SELECT raiz FROM escaneos")} + filtro, args = "", [] + if raiz: + filtro += " AND lower(t.raiz) = ?" + args.append(os.path.normpath(raiz).lower()) + if ids: + filtro += f" AND t.id IN ({','.join('?' * len(ids))})" + args += [int(i) for i in ids] + estados = ",".join(f"'{e}'" for e in _SIGUEN_CLIP) + sin_clip = [dict(r) for r in con.execute( + "SELECT t.* FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta " + f"WHERE a.ruta IS NULL AND t.estado IN ({estados}){filtro}", args)] + sin_salida = [dict(r) for r in con.execute( + "SELECT t.* FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.salida " + f"WHERE a.ruta IS NULL AND t.salida IS NOT NULL AND t.bytes_out > 0 AND t.estado IN ('listo','verificado','reemplazado'){filtro}", args)] + # sin inventario de esa carpeta (no escaneada, o de otro equipo) que no esté no dice nada + sin_clip = [t for t in sin_clip if (t["raiz"] or "").lower() in escaneadas] + sin_salida = [t for t in sin_salida if (t["raiz"] or "").lower() in escaneadas] + if not sin_clip and not sin_salida: + return 0 + nombres = sorted({os.path.basename(t["ruta"] or "") for t in sin_clip} | {os.path.basename(t["salida"] or "") for t in sin_salida}) + donde = defaultdict(list) + for i in range(0, len(nombres), 400): + parte = nombres[i:i + 400] + for r in con.execute(f"SELECT ruta, raiz, nombre, bytes FROM archivos WHERE es_basura = 0 AND nombre IN ({','.join('?' * len(parte))})", parte): + if os.sep + R.DUPLICADOS + os.sep not in r["ruta"]: + donde[((r["raiz"] or "").lower(), r["nombre"], r["bytes"])].append(r["ruta"]) + ocupadas = {(r["ruta"], r["formato"] or "h265_10bit") for r in con.execute( + "SELECT ruta, formato FROM trabajos WHERE estado IN ('pendiente','corriendo','listo','verificado','reemplazado')")} + n = 0 + with con: + for t in sin_clip: + sitio = donde.get(((t["raiz"] or "").lower(), os.path.basename(t["ruta"] or ""), t["bytes_in"])) + if not sitio or len(sitio) != 1: + continue + nueva, fmt = sitio[0], t["formato"] or "h265_10bit" + if (nueva, fmt) in ocupadas and t["estado"] in ("pendiente", "error", "cancelado"): + con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],)) + continue + if (nueva, fmt) in ocupadas: + continue + c = {"ruta": nueva} + if t["estado"] in ("pendiente", "error", "cancelado"): + c["salida"] = salida_para(nueva, t["raiz"], fmt) + if t["estado"] == "error": + c.update(estado="pendiente", pct=0) + if t["estado"] in ("pendiente", "error"): + c["log"] = f"el clip se había movido; ahora está en «{os.path.relpath(os.path.dirname(nueva), t['raiz'])}» y se convierte allí" + con.execute(f"UPDATE trabajos SET {', '.join(k + ' = ?' for k in c)} WHERE id = ?", (*c.values(), t["id"])) + if t["estado"] in ("pendiente", "error", "listo", "verificado"): + ocupadas.add((nueva, fmt)) + n += 1 + for t in sin_salida: + sitio = donde.get(((t["raiz"] or "").lower(), os.path.basename(t["salida"] or ""), t["bytes_out"])) + if sitio and len(sitio) == 1: + con.execute("UPDATE trabajos SET salida = ? WHERE id = ?", (sitio[0], t["id"])) + n += 1 + return n + finally: + con.close() + + def _rango(prefijo): """(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta». «LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento.""" @@ -3348,6 +3488,7 @@ def estimar_bytes(f, perfil, formato): def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"): fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) lee_braw = bool(decodificador_braw().get("ok")) + reubicar_trabajos(raiz) con = db() tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad # es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales @@ -4036,6 +4177,27 @@ def _verificar_listos(ids): COLA["mensaje"] = f"no se pudo verificar: {ex}" +def _clip_presente(t): + """Justo antes de convertir: si el clip ya no está donde dice el trabajo, se busca en el inventario (se + movió). Si no aparece, el trabajo falla diciendo por qué y qué hacer, sin llamar a ffmpeg (antes salía su + «No such file or directory», en inglés y sin pista). Devuelve el trabajo al día, o None.""" + if os.path.isfile(t["ruta"]): + return t + if reubicar_trabajos(ids=[t["id"]]): + con = db() + nuevo = con.execute("SELECT * FROM trabajos WHERE id = ?", (t["id"],)).fetchone() + con.close() + if nuevo and nuevo["estado"] == "pendiente" and os.path.isfile(nuevo["ruta"]): + return dict(nuevo) + try: + donde = os.path.relpath(os.path.dirname(t["ruta"]), t["raiz"]) + except ValueError: + donde = os.path.dirname(t["ruta"]) + set_trabajo(t["id"], estado="error", log=f"el clip ya no está en «{donde}»: se movió o se borró fuera de OrganiLAB. " + "Vuelve a escanear la carpeta: la cola lo buscará en su sitio nuevo y lo convertirá allí.") + return None + + def bucle_cola(): try: while COLA["activa"]: @@ -4045,6 +4207,14 @@ def bucle_cola(): con.close() if t and not _esperar_turno(): break + if t: + t = _clip_presente(dict(t)) + if not t: + continue + if not t and lote_resolve: + lote_resolve = [x for x in map(_clip_presente, lote_resolve) if x] + if not lote_resolve: + continue if t: COLA["actual"] = t["id"] COLA["empezo"] = time.time() @@ -4478,7 +4648,10 @@ def verificar(ids): def reemplazar(ids): - """El original se va a _Para borrar/originales (misma raíz). Nunca se borra.""" + """El original se va a _Para borrar/originales (misma raíz). Nunca se borra. Si el convertido quedó en otra + carpeta que su original (se movió uno y no el otro), antes se lleva junto a él: el convertido ocupa el + sitio del original, que es lo que promete reemplazar.""" + reubicar_trabajos(ids=ids) con = db() res = [] lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia @@ -4503,6 +4676,27 @@ def reemplazar(ids): set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar") continue raiz = t["raiz"] + if not os.path.isfile(t["ruta"]): + res.append({"id": id_, "ok": False, "motivo": "el original ya no está donde se convirtió; vuelve a escanear la carpeta y la cola lo encontrará"}) + continue + if (CONFIG["optimizar"].get("salida") != "espejo" + and os.path.dirname(t["salida"]).lower() != os.path.dirname(t["ruta"]).lower()): + junto = os.path.join(os.path.dirname(t["ruta"]), os.path.basename(t["salida"])) + if os.path.exists(junto): + res.append({"id": id_, "ok": False, "motivo": f"el convertido está en otra carpeta y junto al original ya hay un «{os.path.basename(junto)}»"}) + continue + try: + _renombrar(t["salida"], junto) + except OSError as ex: + res.append({"id": id_, "ok": False, "motivo": f"el convertido está en otra carpeta y no se pudo llevar junto al original: {ex}"}) + continue + movimientos += 1 + bitacora({"lote": lote, "op": "mover", "de": t["salida"], "a": junto, "trabajo": id_}) + with con: + con.execute("UPDATE archivos SET ruta = ?, dir = ? WHERE ruta = ?", (junto, os.path.dirname(junto), t["salida"])) + set_trabajo(id_, salida=junto) + t = dict(t) + t["salida"] = junto raiz_lote = raiz_lote or raiz rel = os.path.relpath(t["ruta"], raiz) destino = os.path.join(raiz, R.PAPELERA, "originales", rel) @@ -7425,6 +7619,7 @@ class Manejador(BaseHTTPRequestHandler): con.close() return self._json({"ok": True}) if acc == "reintentar": + reubicar_trabajos(ids=d.get("ids", [])) con = db() with con: con.execute(f"UPDATE trabajos SET estado = 'pendiente', pct = 0, log = '' WHERE estado IN ('error','cancelado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", [])) @@ -8010,7 +8205,7 @@ def main(): except OSError: pass huerfanas = recuperar_huerfanas() - threading.Thread(target=lambda: (repasar_verificados(), braws_al_lector()), daemon=True).start() + threading.Thread(target=lambda: (reubicar_trabajos(), repasar_verificados(), braws_al_lector()), daemon=True).start() if huerfanas: print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") try: diff --git a/prueba_orden.py b/prueba_orden.py index 0d13aa4..247acea 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -1091,6 +1091,102 @@ class Convertidor(unittest.TestCase): fh.write(b"z" * 999) # alguien dejó otro archivo con ese nombre self.assertTrue(O._salida_ajena(self.trabajo(nuevos[0]))) + def test_la_cola_sigue_al_clip(self): + """Línea Gene, 14-oct-2026: el plan llevó «Material Sony» a 00_Grabacion; 138 conversiones en espera + fallaron buscando cada clip donde ya no estaba, los 11 verificados no se podían reemplazar (y su «_10bit» + se quedó en la carpeta vieja), y volver a escanear no los reenganchaba.""" + raiz = cliente("ColaSigue") + nr = os.path.normpath(raiz) + g = "Material Sony/" + a = mk(raiz, g + "20250722_Labstream3796.MP4", 2025, 7, 22, tam=300000) + b = mk(raiz, g + "20250722_Labstream3797.MP4", 2025, 7, 22, tam=300001) + c = mk(raiz, g + "20250731_Labstream4033.MP4", 2025, 7, 31, tam=300002) + d = mk(raiz, g + "20250731_Labstream4034.MP4", 2025, 7, 31, tam=300003) + O.escanear(raiz) + self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b, c, d)}) + ia, ib, ic = O.encolar([a, b, c], "equilibrado", "420", "rec709", nr) + O.set_trabajo(ib, estado="error", log="falló antes por otra cosa") + # el plan se calcula antes de que c termine de convertirse: su «_10bit» aún no está en el inventario + p = O.Plan(O.raiz_cfg(raiz)).correr() + sc = self.trabajo(ic)["salida"] + with open(sc, "wb") as fh: + fh.write(b"x" * 120000) + O.set_trabajo(ic, estado="verificado", bytes_out=120000) + r = aplicar(raiz, p) + self.assertFalse(os.path.exists(a) or os.path.exists(c)) + + # 1) los trabajos se fueron con sus clips; el que espera sale junto al clip en su sitio nuevo + ta, tb, tc = (self.trabajo(i) for i in (ia, ib, ic)) + for t in (ta, tb, tc): + self.assertTrue(os.path.isfile(t["ruta"]), t) + self.assertIn("00_Grabacion", t["ruta"]) + self.assertEqual(ta["salida"], os.path.splitext(ta["ruta"])[0] + "_10bit.mp4") + self.assertEqual((ta["estado"], tb["estado"]), ("pendiente", "error")) + # …y el convertido acompañó a su original, aunque el plan no lo conocía + self.assertEqual(os.path.dirname(tc["salida"]), os.path.dirname(tc["ruta"])) + self.assertTrue(os.path.isfile(tc["salida"])) + self.assertFalse(os.path.exists(sc)) + # «Buscar clips» no vuelve a ofrecer lo que ya está en la cola o convertido + cs = {os.path.basename(x["ruta"]): x["en_cola"] for x in O.candidatos(nr, 0, "equilibrado", 0)} + self.assertEqual((cs["20250722_Labstream3796.MP4"], cs["20250731_Labstream4033.MP4"], cs["20250731_Labstream4034.MP4"]), + ("pendiente", "verificado", "")) + + # 2) Deshacer los devuelve, y la cola vuelve con ellos + O.deshacer(r["lote"]) + ta, tc = self.trabajo(ia), self.trabajo(ic) + self.assertEqual((ta["ruta"], tc["ruta"], tc["salida"]), (a, c, sc)) + self.assertTrue(os.path.isfile(sc)) + self.assertEqual(ta["salida"], os.path.splitext(a)[0] + "_10bit.mp4") + + # 3) movidos con el Explorador: justo antes de convertir, el que no está falla diciendo qué hacer… + otra = os.path.join(nr, "Otra carpeta") + os.makedirs(otra) + a2, c2 = os.path.join(otra, os.path.basename(a)), os.path.join(otra, os.path.basename(c)) + os.rename(a, a2) + os.rename(c, c2) # el original verificado se va; su convertido, no + self.assertIsNone(O._clip_presente(self.trabajo(ia))) + ta = self.trabajo(ia) + self.assertEqual(ta["estado"], "error") + self.assertIn("Vuelve a escanear", ta["log"]) + # …y al volver a escanear se reengancha: vuelve a la cola en su sitio nuevo, sin volver a leer el clip + O.escanear(raiz) + ta, tc = self.trabajo(ia), self.trabajo(ic) + self.assertEqual((ta["ruta"], ta["estado"]), (a2, "pendiente")) + self.assertEqual(ta["salida"], os.path.splitext(a2)[0] + "_10bit.mp4") + self.assertTrue(ta["log"].startswith("el clip se había movido"), ta["log"]) + self.assertEqual((tc["ruta"], tc["salida"]), (c2, sc)) + con = O.db() + leido = con.execute("SELECT analizado, codec FROM archivos WHERE ruta = ?", (a2,)).fetchone() + con.close() + self.assertEqual(tuple(leido), (1, "h264")) + self.assertEqual(O._clip_presente(self.trabajo(ia))["ruta"], a2) + + # 4) un intento fallido que ya se volvió a encolar en el sitio nuevo sobra: se quita + bn = os.path.join(otra, os.path.basename(b)) + os.rename(b, bn) + O.escanear(raiz) + self.assertEqual(self.trabajo(ib)["ruta"], bn) # el de b (error) se reenganchó y vuelve a esperar + self.assertEqual(self.trabajo(ib)["estado"], "pendiente") + con = O.db() + with con: + viejo = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (b, b + "_10bit.mp4", "ffmpeg", "equilibrado", "420", "rec709", 21150, "error", + 300001, time.time(), nr, "h265_10bit")).lastrowid + con.close() + O.reubicar_trabajos(nr) + con = O.db() + self.assertIsNone(con.execute("SELECT id FROM trabajos WHERE id = ?", (viejo,)).fetchone()) + con.close() + + # 5) reemplazar con el convertido en otra carpeta: primero se lleva junto al original + r = O.reemplazar([ic])[0] + self.assertTrue(r["ok"], r) + junto = os.path.join(otra, os.path.basename(sc)) + self.assertTrue(os.path.isfile(junto)) + self.assertFalse(os.path.exists(sc) or os.path.exists(c2)) + self.assertTrue(os.path.isfile(os.path.join(nr, R.PAPELERA, "originales", "Otra carpeta", os.path.basename(c2)))) + self.assertEqual(self.trabajo(ic)["salida"], junto) + def test_el_codificador_que_toca(self): fm = O.FORMATOS["h265_10bit"] t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000} diff --git a/static/index.html b/static/index.html index 6d229e7..29f190f 100644 --- a/static/index.html +++ b/static/index.html @@ -444,6 +444,13 @@ .cmp .marco img{width:100%;height:100%;object-fit:contain;display:block;cursor:crosshair} .cmp .lupa{position:absolute;width:220px;height:220px;border:2px solid var(--acc);border-radius:8px;background-repeat:no-repeat;background-color:#000;pointer-events:none;display:none;box-shadow:0 4px 14px rgba(0,0,0,.3)} .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 */ + .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} + .cmpcuerpo.vert .cmpt{margin-top:26px} + .cmpcuerpo.vert .cmpnota{grid-template-columns:1fr} .cmpnota{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px} .cmpnota>div{border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-size:12.5px;display:flex;flex-direction:column;gap:4px} .cmpnota b.n{font-size:26px;color:var(--ok)} .cmpnota b.n.baja{color:var(--warn)} @@ -2002,7 +2009,7 @@ async function cargarCola(){ if (corre) { const a = eta && eta.actual && eta.actual.id === t.id ? eta.actual : null; sub = `convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''} · ${motorT}`; } else if (t && t.estado === 'error') sub = `${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}`; else if (t && t.estado === 'descartado') sub = `${esc(t.log || '')}`; - else if (t && t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió')) sub = `${esc(t.log)}`; + else if (t && t.estado === 'pendiente' && t.log) sub = `${esc(t.log)}`; else if (t && ['listo', 'verificado', 'reemplazado'].includes(t.estado)) sub = `${t.nota ? `nota de calidad ${t.nota} / 100 · ` : ''}${esc((t.salida || '').split(/[\\/]/).pop())} · ${motorT}`; else if (c && c.excluido) sub += ' · ' + esc(c.excluido); if (conCarpeta) sub = `${esc(carpeta(f) || '(raíz)')} · ` + sub; @@ -2141,17 +2148,44 @@ function comparar(id){ const lado = (cual, tit, det) => `