diff --git a/README.md b/README.md index d5d1da4..6680d5d 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,10 @@ en la barra de tareas para siempre y bloqueaba desconectar la carpeta. Pruebas a temporal): `py -3.12 probar.py`. Rendimiento medido sobre el inventario real (54 mil archivos): las consultas con LIKE tardan menos de 10 ms; no hacía falta optimizar. +1.6.3: botón «Elegir carpeta…» (barra lateral, pantalla vacía y diálogo de añadir) que abre el selector de +carpetas de Windows, sin depender de soltar la carpeta ni de pegar la ruta. `POST /api/elegir-carpeta` lanza el +diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta (o nulo si se cancela). + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/manifiesto.json b/manifiesto.json index 989123a..2720b8a 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "1.6.2", - "builtAt": "2026-09-15T19:46:00.000Z", - "archivo": "OrganizadorLabstream-portatil-1.6.2.zip", - "size": 198625969, - "sha256": "48ef016966c45d53f6d1d934a6e7bf735c0b20e46413c487ffdd2b4b04b3ef89", + "version": "1.6.3", + "builtAt": "2026-09-16T00:50:31.000Z", + "archivo": "OrganizadorLabstream-portatil-1.6.3.zip", + "size": 198631521, + "sha256": "f84af3a46e4284660acac3dd0fd7fdfee9437c680a899dbfcc3cbf85660a9962", "app": { - "archivo": "OrganizadorLabstream-app-1.6.2.zip", - "size": 14806232, - "sha256": "f45a61a47ffd678f76e6e184fefff6f2cceb860a6b82f8d305b823cebbf2b853", - "url": "/api/herramientas/OrganizadorLabstream-app-1.6.2.zip" + "archivo": "OrganizadorLabstream-app-1.6.3.zip", + "size": 14811784, + "sha256": "8ec8fcacbbd865c4aca52d233667d4281f5510e3bf14044e8efd5173c2beeeb9", + "url": "/api/herramientas/OrganizadorLabstream-app-1.6.3.zip" }, "notes": "", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "013a3ac0886f655e22e2a30913be8a54880e43ec5663dcda8537a220a014438998533b1d440b84e33e7393e8d544089c215d7af3acb27390471ffa728da1d4f452cffad7534c51041f40695152431086b165348219fba51de93b1ae0403a3b748fc1e0022a707797868825d934d21d676debc6cfa8b54d6e7f4b0bd5c83b6ed6a9fe1f60bcee5cc9e5aa06710e120c2bbcdbcd59356e9931aed4c3206815b8c99746940be562a196144c9e22c4e0aa17ea43852c79a9b6e23076ff18c89c76971946aed10e05ca29aaedc95f3e0d39ffa71f020125d7bf272d9a5f9ef8b5f6b6e5cbb4930e12b4c598b6343bd779c1d0ead18aad70eaa442badc303ad22bbca3" + "valor": "58afe4902d9cc98d02ffb98a3376de4dd9da56504c24a88ef1ff3175f8832cac22a20ae9439aca68cba6bbb2126441770b3abe190f5d99d6b313830dd4cc7c4000eede641e97a169f7cebdf541abd618a6f27e032d0c1931aa567849f91a63489b67f3d062a4ea3fd6478d5479a6da9c0cfd56242afbf133ecc2558c95448e443054fd8879a03ccf54c0e0e23057f9923a7ef0fac270ea8f512aab2caf1b1c506f9e8ea749604171a2364d9054901582b455681947d9a26b55459b5b880023d41d532730324daeec88175990a0883c96ad670f79925e35335c68658ccfc70326a7adead5b95c6f3b6d5f309dfc5214734f039397ff9a05b2348643bce27c54fa" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index db1cd70..a91e3ad 100644 --- a/organizador.py +++ b/organizador.py @@ -33,7 +33,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "1.6.2" +VERSION = "1.6.3" CONGELADO = getattr(sys, "frozen", False) SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller @@ -1560,9 +1560,34 @@ def bucle_cola(): COLA["hilo"] = None +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.""" + 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"])) + con.close() + return len(filas) + + +class ErrorCola(ValueError): + """Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto.""" + def __init__(self, mensaje, datos): + super().__init__(mensaje) + self.datos = datos + + def iniciar_cola(): if COLA["hilo"] and COLA["hilo"].is_alive(): return False + recuperar_huerfanas() COLA["activa"] = True COLA["cancelar"] = False COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True) @@ -2032,8 +2057,31 @@ def _clave(ruta): return os.path.normpath(ruta) -def quitar_raiz(ruta, borrar_datos=False): - """Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear).""" +_ELIGIENDO = threading.Lock() + + +def elegir_carpeta(): + """Abre el selector de carpetas de Windows (en un PowerShell aparte, para no bloquear el servidor) y devuelve la ruta o None.""" + if not _ELIGIENDO.acquire(blocking=False): + raise ValueError("ya hay un selector de carpetas abierto") + try: + ps = ("Add-Type -AssemblyName System.Windows.Forms; " + "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " + "$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; " + "$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true; " + "if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }") + flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", ps], capture_output=True, timeout=600, creationflags=flags) + ruta = out.stdout.decode("utf-8", "replace").strip() or out.stdout.decode("mbcs", "replace").strip() + return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None + finally: + _ELIGIENDO.release() + + +def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False): + """Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear). + Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están + convirtiendo ahora mismo nunca se descartan desde aquí: hay que detener la cola primero.""" k = _clave(ruta) r = raiz_cfg(ruta) if not r: @@ -2042,11 +2090,27 @@ def quitar_raiz(ruta, borrar_datos=False): raise ValueError("la carpeta se está escaneando o analizando; espera a que termine") if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"] and (APLICANDO.get("raiz") or "").lower() == k.lower(): raise ValueError("se está aplicando el plan de esa carpeta; espera a que termine") + recuperar_huerfanas() con = db() - activos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo')", (k.lower(),)).fetchone()["n"] + activos = [dict(x) for x in con.execute("SELECT id, ruta, estado, pct FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo') ORDER BY id", (k.lower(),)).fetchall()] if activos: - con.close() - raise ValueError(f"hay {activos} conversiones pendientes o en curso de esa carpeta; detén la cola o límpiala primero") + en_curso = [x for x in activos if x["estado"] == "corriendo"] + if en_curso or not descartar_cola: + con.close() + n = len(activos) + por_estado = {"corriendo": len(en_curso), "pendiente": n - len(en_curso)} + que = ", ".join(f"{v} {'convirtiéndose ahora' if e == 'corriendo' else 'en espera'}" for e, v in por_estado.items() if v) + # El texto dice QUÉ hay y QUÉ hacer; los datos (archivos, estados) van aparte para que la + # interfaz los enseñe y ofrezca descartar. Antes solo decía «detén la cola o límpiala». + if en_curso: + msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): detén la cola en Convertir a 10 bits y vuelve a intentarlo" + else: + msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): termínalas con Reanudar en Convertir a 10 bits, o descártalas al desconectar" + raise ErrorCola(msg, {"cola": {"n": n, "por_estado": por_estado, "en_curso": bool(en_curso), + "archivos": [os.path.basename(x["ruta"]) for x in activos[:6]]}}) + with con: + con.execute("DELETE FROM trabajos WHERE lower(raiz) = ? AND estado = 'pendiente'", (k.lower(),)) + bitacora({"op": "cola_descartada", "raiz": k, "n": len(activos), "archivos": [x["ruta"] for x in activos]}) CONFIG["raices"] = [x for x in CONFIG["raices"] if _clave(x["ruta"]).lower() != k.lower()] guardar_json(CONFIG_PATH, CONFIG) PLANES.pop(k, None); PROGRESO.pop(k, None); ANALISIS.pop(k, None) @@ -2283,9 +2347,16 @@ class Manejador(BaseHTTPRequestHandler): try: if ruta == "/api/escanear": return self._json({"lanzado": lanzar_escaneo(d["raiz"])}) + if ruta == "/api/elegir-carpeta": + try: + return self._json({"ruta": elegir_carpeta()}) + except ValueError as e: + return self._json({"error": str(e)}, 409) if ruta == "/api/raiz/quitar": try: - return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")))) + return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")), bool(d.get("descartar_cola")))) + except ErrorCola as e: + return self._json({"error": str(e), **e.datos}, 409) except ValueError as e: return self._json({"error": str(e)}, 409) if ruta == "/api/raices": @@ -2698,6 +2769,9 @@ def main(): h = herramientas() print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"]) print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)") + huerfanas = recuperar_huerfanas() + if huerfanas: + print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") try: srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador) except OSError: diff --git a/static/index.html b/static/index.html index 1dbc061..60faf7b 100644 --- a/static/index.html +++ b/static/index.html @@ -249,6 +249,7 @@
Suelta aquí la carpeta
de un cliente para añadirla
+
Labstream Studio
@@ -269,7 +270,7 @@

Suelta aquí la carpeta de un cliente

OrganiLAB la escanea, te enseña qué pesa y te lleva paso a paso. No mueve nada sin que lo confirmes, y nunca borra.

-
o pega la ruta de la carpeta
+
  o pega la ruta
1 · EscanearLee nombres, tamaños y fechas. Solo lectura.
@@ -372,6 +373,7 @@ +
Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.
@@ -452,11 +454,18 @@ async function api(u, body){ try { r = await fetch(u, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : undefined); } catch (e) { throw new ApiError('No hay conexión con OrganiLAB. ¿Se cerró el programa?'); } let j = null; try { j = await r.json(); } catch (e) { throw new ApiError(`Respuesta rara del programa (${r.status})`); } - if (!r.ok || (j && j.error)) throw new ApiError((j && j.error) || `Error ${r.status}`); + // el JSON del error viaja con la excepción: trae la traza del servidor o, en «Desconectar», qué conversiones estorban + if (!r.ok || (j && j.error)) { const e = new ApiError((j && j.error) || `Error ${r.status}`); e.datos = j || {}; throw e; } return j; } -function aviso(texto, tipo, enlace){ +// Los avisos de error se apuntan (últimos 40, en este navegador) para poder leerlos después de que el globo +// desaparezca: salen en Historial › «Avisos recientes», con su detalle si lo hay. +const AVISOS_CLAVE = 'organilab.avisos'; +function avisosGuardados(){ try { return JSON.parse(localStorage.getItem(AVISOS_CLAVE) || '[]'); } catch (e) { return []; } } +function apuntarAviso(texto, detalle){ try { const l = avisosGuardados(); l.unshift({ts: Date.now() / 1000, texto, detalle: detalle || '', raiz: (typeof raizActual === 'function' && $('#raiz')) ? raizActual() : ''}); localStorage.setItem(AVISOS_CLAVE, JSON.stringify(l.slice(0, 40))); } catch (e) {} } +function aviso(texto, tipo, enlace, detalle){ const d = document.createElement('div'); d.className = 'aviso ' + (tipo || ''); + if (tipo === 'bad') { apuntarAviso(texto, detalle); if (!enlace) enlace = {texto: 'Detalle', ir: () => modal('Qué pasó', `

${esc(texto)}

${detalle ? `
${esc(detalle)}
` : ''}

Queda apuntado en Historial › Avisos recientes.

`, null)}; } d.innerHTML = `${esc(texto)}${enlace ? `${esc(enlace.texto)}` : ''}✕`; if (enlace) d.querySelector('a').onclick = () => { enlace.ir(); d.remove(); }; d.querySelector('.x').onclick = () => d.remove(); @@ -466,11 +475,13 @@ async function accion(btn, fn){ if (btn.disabled) return; const antes = btn.innerHTML; btn.disabled = true; btn.innerHTML = '' + antes; try { await fn(); } - catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad'); } + catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } finally { btn.disabled = false; btn.innerHTML = antes; } } +// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí +const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || '')); function bloquear(id, motivo){ const b = $('#' + id); if (!b) return; b.disabled = !!motivo; const p = $('#' + id + 'PorQue'); if (p) p.textContent = motivo && motivo !== 'x' ? motivo : ''; } -const seguro = fn => async (...a) => { try { await fn(...a); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad'); } }; +const seguro = fn => async (...a) => { try { await fn(...a); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } }; $$('.ayuda .q').forEach(q => q.onclick = () => q.closest('.ayuda').classList.toggle('abierta')); function raizActual(){ return $('#raiz').value; } @@ -607,14 +618,21 @@ function desconectar(clave){ const ocupada = tareasDe(ESTADO).some(t => (t.raiz || '').toLowerCase() === clave.toLowerCase()); if (ocupada) { aviso('Esa carpeta tiene una tarea en curso. Espera a que termine.', 'bad'); return; } modal('Desconectar «' + r.cliente + '»', `

La carpeta ${esc(r.ruta)} desaparece de la lista de la izquierda. No se borra ni se mueve nada del disco; puedes volver a soltarla cuando quieras.

`, async () => { + const olvidar = $('#mDatos').checked; + const listo = async (n) => { aviso(n ? `Carpeta desconectada; ${n} conversión${n > 1 ? 'es' : ''} descartada${n > 1 ? 's' : ''}.` : 'Carpeta desconectada.', 'ok'); await cargarEstado(); const s = $('#raiz'); if (s.value === clave) { s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); } else { pintarIndice(); cargarVista(); } }; try { - const era = raizActual() === clave; - await api('/api/raiz/quitar', {raiz: clave, datos: $('#mDatos').checked}); - aviso('Carpeta desconectada.', 'ok'); - await cargarEstado(); - if (era) { const s = $('#raiz'); s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); } - else pintarIndice(); - } catch (e) { aviso(e.message, 'bad'); } + await api('/api/raiz/quitar', {raiz: clave, datos: olvidar}); + await listo(0); + } catch (e) { + const c = e.datos && e.datos.cola; + if (!c) { aviso(e.message, 'bad', null, detalleDe(e)); return; } + // Conversiones sin terminar: se enseñan (cuáles, en qué estado) y, si ninguna está convirtiéndose ahora, + // se ofrece descartarlas y desconectar de una vez. Antes solo salía un globo sin decir cuáles. + apuntarAviso(e.message, c.archivos.join('\n')); + modal('No se pudo desconectar «' + r.cliente + '»', `

${esc(e.message)}.

${c.n} conversión${c.n > 1 ? 'es' : ''} sin terminar${c.por_estado.corriendo ? ` (${c.por_estado.corriendo} convirtiéndose ahora)` : ''}:

${esc(c.archivos.join('\n'))}${c.n > c.archivos.length ? `\n… y ${c.n - c.archivos.length} más` : ''}

${c.en_curso ? 'Detén la cola en Convertir a 10 bits y vuelve a intentarlo.' : 'Descartarlas las quita de la cola sin tocar ningún archivo: los originales siguen donde están, y si vuelves a conectar la carpeta puedes encolarlas otra vez.'}

`, + c.en_curso ? null : async () => { try { await api('/api/raiz/quitar', {raiz: clave, datos: olvidar, descartar_cola: true}); await listo(c.n); } catch (e2) { aviso(e2.message, 'bad', null, detalleDe(e2)); } }); + if (!c.en_curso) $('#modalSi').textContent = 'Descartar y desconectar'; + } }); } $('#btnDesconectar').onclick = () => desconectar(raizActual()); @@ -667,13 +685,18 @@ function pasosDe(C, r){ const cv = C.convertir || {}; const tr = cv.trabajos || {}; const n = k => (tr[k] || {}).n || 0; const activos = n('pendiente') + n('corriendo'), listos = n('listo'), verif = n('verificado'), reemp = n('reemplazado'); const anaRun = cv.analisis && cv.analisis.estado === 'corriendo'; - P.push(!esc ? {e:'bloq', t:'Convertir', d:'Después de escanear'} : !cv.camara_bytes ? {e:'hecho', t:'Convertir', d:'No hay material de cámara', corto: 'nada'} + const conError = n('error') + n('cancelado'); + // Las conversiones sin terminar o con error se enseñan SIEMPRE, aunque la carpeta ya no tenga material de + // cámara (disco desconectado, material movido): antes el paso decía «nada» mientras la cola bloqueaba Desconectar. + P.push(!esc ? {e:'bloq', t:'Convertir', d:'Después de escanear'} : anaRun ? {e:'curso', t:'Convertir', d:`Leyendo clips ${cv.analisis.hechos} de ${cv.analisis.total}`, corto: 'leyendo'} : cv.cola_activa && activos ? {e:'curso', t:'Convertir', d:`${activos} clip${activos > 1 ? 's' : ''} en cola`, corto: `${activos} en cola`} + : activos ? {e:'listo', t:'Convertir', d:`${activos} conversión${activos > 1 ? 'es' : ''} sin terminar, cola parada`, corto: `${activos} parada${activos > 1 ? 's' : ''}`} + : conError ? {e:'listo', t:'Convertir', d:`${conError} conversión${conError > 1 ? 'es' : ''} con error`, corto: `${conError} con error`} + : !cv.camara_bytes ? {e:'hecho', t:'Convertir', d:'No hay material de cámara', corto: 'nada'} : listos ? {e:'listo', t:'Convertir', d:`${listos} convertido${listos > 1 ? 's' : ''} por verificar`, corto: `${listos} sin verificar`} : verif ? {e:'listo', t:'Convertir', d:`${verif} verificado${verif > 1 ? 's' : ''} por reemplazar`, corto: `${verif} por reemplazar`} - : reemp && !activos ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya en 10 bits`, corto: `${reemp} hechos`} - : activos ? {e:'listo', t:'Convertir', d:`${activos} en cola, parada`, corto: 'parada'} + : reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya en 10 bits`, 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 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'} @@ -879,6 +902,7 @@ async function cargarCola(){ 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` : ''}` : 'sin actividad'; const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar'; $('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado'].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'; const validos = CAND.filter(c => !c.excluido), ahora = validos.reduce((s,c)=>s+c.bytes,0), despues = validos.reduce((s,c)=>s+c.estimado,0); const hechosB = ts.filter(t => t.bytes_out).reduce((s,t) => s + (t.bytes_in - t.bytes_out), 0); @@ -891,7 +915,7 @@ async function cargarCola(){ } if (ts.length) { html += `
Conversiones${ts.length}
`; - html += ts.slice(0, 120).map(t => `
${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}${esc((t.ruta || '').split(/[\\/]/).pop())} · ${t.motor === 'resolve' ? 'Resolve' : 'ffmpeg · GPU'}${t.estado === 'error' ? `${esc((t.log || '').slice(-110))}` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}${t.estado === 'corriendo' ? `
` : ''}
${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}${t.bytes_out ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) + '%' : ''}
`).join('') + (ts.length > 120 ? `
… y ${ts.length - 120} más
` : ''); + html += ts.slice(0, 120).map(t => `
${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}${esc((t.ruta || '').split(/[\\/]/).pop())} · ${t.motor === 'resolve' ? 'Resolve' : 'ffmpeg · GPU'}${t.estado === 'error' ? `${esc((t.log || '').slice(-110))}` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `${esc(t.log)}` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}${t.estado === 'corriendo' ? `
` : ''}
${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}${t.bytes_out ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) + '%' : ''}
`).join('') + (ts.length > 120 ? `
… y ${ts.length - 120} más
` : ''); } $('#convLista').innerHTML = html || 'Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.'; $$('#convLista input[data-i]').forEach(i => i.onchange = () => { CAND[+i.dataset.i].sel = i.checked; resumenCand(); }); @@ -903,6 +927,9 @@ $('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { cons $('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `

${ids.length} originales se apartan a _Para borrar/originales dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.

Cuando estés seguro, una persona vacía _Para borrar a mano.

`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `
${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}
`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; }); $('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); }); $('#btnQuitar').onclick = () => accion($('#btnQuitar'), async () => { const ids = await idsPorEstado('error', 'cancelado', 'reemplazado'); await api('/api/optimizar/control', {accion: 'quitar', ids}); cargarCola(); }); +// Con la cola parada, las que esperan también se pueden quitar (antes no había forma desde la interfaz, y +// bloqueaban Desconectar). No toca archivos: solo las saca de la cola. +$('#btnQuitarEspera').onclick = () => accion($('#btnQuitarEspera'), async () => { const ids = await idsPorEstado('pendiente'); if (!ids.length) throw new ApiError('No hay conversiones en espera de esta carpeta.'); modal('Quitar las conversiones en espera', `

${ids.length} conversión${ids.length > 1 ? 'es' : ''} en espera se quita${ids.length > 1 ? 'n' : ''} de la cola. No se toca ningún archivo: los originales siguen donde están y puedes volver a encolarlas desde «Buscar clips».

`, () => accion($('#btnQuitarEspera'), async () => { await api('/api/optimizar/control', {accion: 'quitar', ids}); await cargarEstado(); cargarCola(); })); $('#modalSi').textContent = 'Quitar'; }); // ---------------- respaldar ---------------- function pintarRespaldo(){ @@ -944,8 +971,12 @@ async function cargarBit(){ const dias = {}; lotes.forEach(l => { const k = l.ts ? new Date(l.ts * 1000).toDateString() : '?'; (dias[k] = dias[k] || []).push(l); }); const hoy = new Date().toDateString(), ayer = new Date(Date.now() - 86400000).toDateString(); const titulo = k => k === hoy ? 'Hoy' : k === ayer ? 'Ayer' : new Date(k).toLocaleDateString('es-CO', {weekday: 'long', day: 'numeric', month: 'long'}); - $('#historial').innerHTML = Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
${l.deshecho || l.lote === 'optimizar' ? '' : ``}
`).join('')).join('') || '
Todavía no se ha movido nada.
'; + // Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron. + const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz); + const avHtml = av.length ? `
Avisos recientes
` + av.slice(0, 20).map(a => `
${hora(a.ts)}${esc(a.texto)}${!solo && a.raiz ? ` · ${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}` : ''}${a.detalle ? `
detalle
${esc(a.detalle)}
` : ''}
`).join('') : ''; + $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
${l.deshecho || l.lote === 'optimizar' ? '' : ``}
`).join('')).join('') || '
Todavía no se ha movido nada.
'); $$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l)); + const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); }; } $('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad')); @@ -1000,7 +1031,15 @@ document.addEventListener('drop', seguro(async e => { if (d.coincidencias.length === 1) return proponerRaiz(d.coincidencias[0]); modal('¿Cuál es?', `

Hay varias carpetas con ese nombre:

${d.coincidencias.map((c,i) => ``).join('')}`, () => proponerRaiz(document.querySelector('input[name=cand]:checked').value)); })); -zona.onclick = () => modal('Añadir carpeta de cliente', '

Pega la ruta completa de la carpeta (en el Explorador: Mayús + clic derecho → «Copiar como ruta de acceso»).

', () => proponerRaiz($('#rutaPegada').value.trim().replace(/^"|"$/g, ''))); +async function elegirCarpeta(btn){ + await accion(btn, async () => { + const d = await api('/api/elegir-carpeta', {}); + if (d.ruta) await proponerRaiz(d.ruta); + }); +} +zona.onclick = () => { modal('Añadir carpeta de cliente', '

abre el selector de Windows

O pega la ruta completa (en el Explorador: Mayús + clic derecho → «Copiar como ruta de acceso»):

', () => { const v = $('#rutaPegada').value.trim().replace(/^"|"$/g, ''); if (v) proponerRaiz(v); }); $('#btnElegir3').onclick = () => { $('#modal').classList.remove('on'); elegirCarpeta($('#btnElegir')); }; }; +$('#btnElegir').onclick = () => elegirCarpeta($('#btnElegir')); +$('#btnElegir2').onclick = e => { e.stopPropagation(); elegirCarpeta($('#btnElegir2')); }; zona.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); zona.onclick(); } }; $('#vacioCaja').onclick = () => zona.onclick(); $('#vacioCaja').onkeydown = zona.onkeydown; function proponerRaiz(ruta){