La cola de conversión sigue a los clips cuando se mueven, y «Comparar» en vertical
Los trabajos de la cola apuntaban al clip por su ruta y nadie los avisaba al moverlo: en Línea Gene el plan llevó «Material Sony» a 00_Grabacion y 138 conversiones fallaron, los 11 verificados no se podían reemplazar y volver a escanear no los reenganchaba. Ahora se mueven con el clip (plan, Deshacer, renombrar), el convertido acompaña a su original, al escanear se reenganchan por nombre y peso, antes de convertir se comprueba que el clip esté, y reemplazar junta antes el convertido con su original. El comparador toma la forma del clip y la lupa ya no estira el recorte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
201
organizador.py
201
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:
|
||||
|
||||
Referencia en una nueva incidencia
Block a user