commit 8c5153e8ed1281c88f1a90006cec6978861c9825 Author: Labstream Date: Tue Sep 15 14:26:02 2026 -0500 OrganiLAB 1.6.1: organizador, actualizador, interfaz 1.6 y publicación Primer commit del proyecto. Incluye el rediseño 1.6 (riel de pasos en la barra lateral), el actualizador con reintentos y respaldo, publicar.sh y la nueva opción de desconectar carpetas (1.6.1). Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78dce81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# generado al empaquetar +build/ +dist/ +*.zip +__pycache__/ +*.pyc +# datos locales de la instalación (inventario, config, bitácora) +datos/ +# copias de respaldo puntuales +_respaldo-*/ diff --git a/AUDITORIA-2026-09-15.html b/AUDITORIA-2026-09-15.html new file mode 100644 index 0000000..0705e22 --- /dev/null +++ b/AUDITORIA-2026-09-15.html @@ -0,0 +1,261 @@ +Auditoría OrganiLAB 1.4.3 + + +
+

Auditoría OrganiLAB 1.4.3

+

Organizador Labstream · código en C:\Users\Munka\Claude\labstream-organizador · 15 sep 2026 · solo lectura, no se modificó nada.

+ +
+

Veredicto. La app cumple hoy su promesa de no borrar: en la bitácora real (raíz V:\Galeano, 1.154 movimientos, 6 lotes) no hay un solo error. Pero hay dos fallas que sí pueden destruir material de cliente por una ruta que la promesa no cubre: la conversión a 10 bits sobrescribe salidas cuando un clip existe en MP4 y MOV, y el renombrado silencioso con «(2)» rompe el emparejamiento de clips Sony con sus XML. Además, el servidor local acepta órdenes de cualquier página web abierta en el navegador, lo que permite mover archivos o instalar un ejecutable ajeno.

+
+ +
+
4Críticos
+
11Altos
+
15Medios
+
12Bajos
+
+ +

Qué arreglar primero

+
    +
  1. Salidas de conversión a prueba de colisión. Conservar la extensión original en el nombre (clip.mov_10bit.mp4), rechazar encolar si otro trabajo ya reclama esa salida, y nunca hacer os.remove ni os.replace sobre un archivo existente. En «Reemplazar», volver a comprobar que la salida existe y usar un lote único por reemplazo.
  2. +
  3. Cerrar el API local. Rechazar peticiones cuyo Host no sea 127.0.0.1:8765, exigir Content-Type: application/json y un token aleatorio por arranque que la interfaz envíe. Quitar ffmpeg, ffprobe, python_resolve y actualizaciones.url de lo que /api/config puede cambiar.
  4. +
  5. Nunca renombrar por colisión. Convertir la colisión en aviso en el plan y en error en ejecución (saltar el archivo y registrarlo), en vez del sufijo «(2)». Reconocer el sidecar Sony C0001M01.XML en las reglas.
  6. +
  7. Contención de rutas. En aplicar, stock a papelera, duplicados y respaldo, rechazar cualquier origen o destino que no cuelgue de la raíz. Corregir la comprobación de papelera, que aún busca «papelera» y no «_Para borrar».
  8. +
  9. Actualizador seguro. Validar versión y nombre de archivo del manifiesto, exigir sha256 y HTTPS del mismo host, no actualizar con trabajos en curso, y hacer copia de la instalación antes del robocopy para poder volver atrás.
  10. +
+ +

Seguridad de los datos

+ +
Crítico
+

MP4 y MOV del mismo clip comparten una sola salida de conversión

+

La salida se forma quitando la extensión y añadiendo _10bit.mp4. clip.mov y clip.mp4 producen el mismo destino; el segundo trabajo sobrescribe al primero, la verificación aprueba a ambos y «Reemplazar» manda los dos originales a _Para borrar/originales. Solo sobrevive un convertido. El puente con Resolve lo hace explícito con os.remove(dst). El asistente automatiza toda la cadena sin revisión humana.

+

organizador.py:1272-1278, 1289, 1373, 1487-1553 · resolve_bridge.py:240-241 · asistente.py:267-270

+
+ +
Alto
+

Colisiones de nombre se resuelven renombrando con «(2)»

+

Al fusionar grabaciones de varias tarjetas Sony en un mismo 00_Grabacion/SONY/, el segundo C0001.MP4 pasa a C0001 (2).MP4. Ese nombre ya no se reconoce como cámara y el siguiente plan lo propone mover a Exportados. Su XML queda huérfano y Resolve o Premiere no pueden reenlazar. No hay aviso en el plan.

+

organizador.py:868-875, 894, 692-750, 1541 · reglas.py:102

+
+ +
Alto
+

La carpeta «_Para borrar» se vuelve a planificar como basura en cada pasada

+

La comprobación sigue buscando el nombre viejo «papelera». Como el escaneo marca _Para borrar como basura, el plan propone moverla dentro de sí misma con confianza «alta» y marcada por defecto. Hoy falla con «Cannot move a directory into itself» y deja una carpeta parásita; cualquier cambio futuro en mover() lo convertiría en un automovimiento recursivo.

+

organizador.py:816, 827 · reglas.py:539-540

+
+ +
Alto
+

Archivos bloqueados dejan copias huérfanas sin registrar

+

Si un clip está abierto en Resolve, os.rename falla y el respaldo shutil.move copia el archivo pero no puede borrar el origen. La excepción sale antes de escribir la bitácora: origen intacto, copia de varios GB en destino, nada registrado. Reintentar crea copias «(2)».

+

organizador.py:895-899

+
+ +
Alto
+

Todos los reemplazos comparten el lote «optimizar»

+

Deshacer ese lote devuelve a su sitio todos los originales reemplazados en la historia de la app. «Reemplazar» tampoco vuelve a comprobar que el convertido siga existiendo: si se borró entre la verificación y el reemplazo, el original se va sin sustituto.

+

organizador.py:1538, 972-994

+
+ +
Alto
+

Destinos editables fuera de la raíz y movimientos entre unidades sin verificar

+

El campo de destino del plan acepta rutas absolutas y ..\.. sin comprobar contención. Entre unidades, shutil.move copia y borra sin comparar tamaño ni hash, y «Deshacer» solo usa os.rename, así que no puede revertirlo.

+

organizador.py:2104, 436-437, 878-900, 985

+
+ +
Medio
+

Deshacer omite entradas en silencio y mezcla lotes del mismo segundo

+

Si el destino ya no existe o el origen está ocupado, la entrada se salta sin avisar. El id de lote tiene resolución de segundo; la bitácora del proyecto ya muestra un lote con dos «fin». Las carpetas vaciadas se borran sin registro.

+

organizador.py:983, 907, 957-969

+
+ +
Medio
+

Aplicación parcial ante rutas largas

+

Una fila de 500 archivos se detiene en el primer fallo. Las rutas bajo _Para borrar/basura/ superan fácilmente 260 caracteres en NAS; no hay comprobación previa ni prefijo \\?\.

+

organizador.py:913-928

+
+ +
Medio
+

El puente con Resolve modifica y guarda el proyecto abierto del editor

+

Si no puede crear su propio proyecto, usa el que el editor tiene abierto: cambia resolución y fps, borra todos los trabajos de render en cola, añade bins y timelines, y guarda. Los temporales _render_* quedan dentro de la carpeta de cámara del cliente si algo falla o se cancela.

+

resolve_bridge.py:105-114, 155-159, 185, 260, 151, 250-255 · organizador.py:1440

+
+ +
Bajo
+

Masters dentro de una pieza son candidatos a conversión

+

Solo se excluye el contexto «Exportados». Un 01- Pieza/final.mp4 se ofrece para convertir y tras «Reemplazar» el master entregado acaba en _Para borrar/originales. «Apartar duplicados» sin selección aplica todos los grupos.

+

organizador.py:1257, 2166

+
+ +

Reglas de reconocimiento

+ +
Alto
+

El sidecar Sony C0001M01.XML no se reconoce

+

El patrón solo admite c0001.xml. El XML va a OTROS/ o a 01_Proyectos/Edicion mientras el MP4 va a SONY/. Sin embargo «Reemplazar» depende de ese nombre exacto para moverlo junto al clip.

+

reglas.py:101-102 · organizador.py:1541

+
+ +
Medio
+

BRAW nunca llega a ser candidato a conversión

+

ffprobe no lee BRAW, el análisis lo marca como error y los candidatos exigen análisis correcto. La rama BRAW es inalcanzable: toda la vía Resolve está muerta desde la interfaz.

+

organizador.py:1218-1226, 1241

+
+ +
Medio
+

Duplicados sin comprobar contenido

+

Se agrupan solo por nombre y tamaño, sin hash ni siquiera parcial. Clips de cámara a bitrate fijo con igual duración se declaran idénticos y uno se aparta. La pareja «versión» separa también un MOV intermedio de su MP4 master.

+

organizador.py:1697-1723

+
+ +
Medio
+

Carpetas «1» a «12» bajo un año se renombran como meses con confianza alta

+

Piezas numeradas sin título (2025/3) pasan a 03- Marzo marcadas por defecto.

+

reglas.py:62 · organizador.py:187-195

+
+ +
Bajo
+

Otros

+

Raíces de unidad (N:\) rompen las consultas por prefijo y dejan el mapa vacío. .dat e .idx se tratan como basura. Cualquier carpeta llamada «Cache» se clasifica como proxy. El año de clips Blackmagic se infiere de la fecha de modificación.

+

organizador.py:1066-1071, 1802 · reglas.py:156, 159, 268-277

+
+ +

Seguridad

+ +
Crítico
+

Cualquier página web puede dar órdenes al servidor local

+

Escucha solo en 127.0.0.1, pero no valida Host ni Origin, no pide token y parsea JSON sin mirar Content-Type. Un fetch en modo no-cors desde cualquier sitio abierto en el navegador pasa sin preflight. Con eso se puede reorganizar cualquier carpeta, mover rutas fuera de la raíz vía /api/stock/papelera o copiar el perfil entero a un recurso de red ajeno vía respaldo. /api/respaldo/plan es un GET que escribe config.

+

organizador.py:2459, 1916-1923, 1615-1629, 2025-2034, 2169-2177

+
+ +
Crítico
+

Dos cadenas de ejecución de código desde ese mismo agujero

+

Uno: /api/config acepta cualquier clave, incluida la ruta de ffprobe; un UNC de atacante se ejecuta al analizar. Dos: cambiar actualizaciones.url y llamar a /api/actualizacion/instalar descarga un zip ajeno, lo comprueba contra el sha256 del propio atacante y lo lanza como ejecutable.

+

organizador.py:2060-2067, 144-150, 1181-1185, 2113 · actualizador.py:47, 62, 115

+
+ +
Crítico
+

El actualizador confía en todo lo que dice el manifiesto

+

Sin firma. El sha256 se omite si el campo falta. La URL de descarga puede apuntar a otro host y a http. La cadena de versión se inserta sin validar en el .bat (inyección de comandos) y el nombre de archivo permite ..\ para escribir fuera de la carpeta de actualización.

+

actualizador.py:49-51, 62, 99, 114-116, 138, 144

+
+ +
Alto
+

La actualización mata la app con trabajos en curso y no puede volver atrás

+

os._exit a los 1,5 s salta la espera de conversión, respaldo y asistente. El bat solo espera a ese PID, no a otras instancias del exe. Robocopy sobrescribe archivo a archivo sin copia previa; un fallo a mitad deja exe viejo con _internal nuevo. El bat se escribe en cp1252 sin chcp, así que rutas con acentos fallan en consola OEM 850.

+

actualizador.py:159, 147-149, 142

+
+ +

Correcto: no hay shell=True, el handler de /static/ no permite traversal, enlaces simbólicos no se siguen, #recycle y @eadir se omiten, datos\ y ffmpeg\ quedan fuera del robocopy.

+ +

Interfaz

+ +
Alto
+

Estado viejo al cambiar de raíz

+

Cambiar la raíz solo limpia plan y candidatos. Con la tabla de stock de la raíz A en pantalla y la raíz B seleccionada, «Mover a _Para borrar» envía rutas de A con raíz B; el servidor no valida y relpath con .. saca el destino fuera de la papelera. «Copiar respaldo» sigue activo con el plan de otra raíz.

+

index.html:461, 478, 487 · organizador.py:1615-1629, 2171

+
+ +
Alto
+

Errores del servidor se tratan como éxito

+

api() alerta pero devuelve el objeto de error; los llamadores hacen .filter o .map sobre él y revientan. Si el servidor no responde JSON, la promesa muere sin manejar y botones como «Calcular plan» quedan deshabilitados para siempre.

+

index.html:419, 470, 481, 584-590, 614, 625

+
+ +
Alto
+

Doble clic duplica trabajos de conversión

+

«Encolar y arrancar» no se deshabilita y encolar() hace SELECT y luego INSERT sin bloqueo en hilos distintos. Igual con Verificar, Calcular respaldo y Stock a papelera.

+

index.html:625, 635, 487, 478 · organizador.py:1289-1295

+
+ +
Medio
+

Otros de interfaz

+
    +
  • Ajustes se repinta desde el sondeo cada 2,5 s y descarta lo que se está escribiendo.
  • +
  • Claves de raíz sin normalizar: una raíz con barra final aparece «no escaneada» para siempre.
  • +
  • El plan pinta todas las filas sin límite y se repinta entero en cada tecla del filtro; con 25.000 archivos son MB de JSON.
  • +
  • Valores de perfil, croma y nombres de plantilla se pintan sin escapar; combinado con el CSRF es XSS persistente. Los nombres de archivo sí se escapan.
  • +
  • Doble escape en el título del asistente: «Brigard & Urrutia» se ve con &.
  • +
  • Modal sin rol, sin Escape ni foco; enlaces sin href no operables por teclado; contraste del ámbar por debajo de AA. Google Fonts cargado en una herramienta que puede estar sin red.
  • +
+

index.html:648-659, 463, 441, 585-601, 630, 655, 717, 414, 190-197, 8

+
+ +

Robustez y rendimiento

+ +
Alto
+

Hilos de fondo mueren en silencio y dejan banderas atascadas

+

analizar() no captura excepciones: un «database is locked» durante un escaneo grande deja el análisis en «corriendo» hasta reiniciar. bucle_cola() sin try/finally deja COLA.activa en true y el cierre de ventana nunca termina. copiar_respaldo escribe «corriendo» pero el cierre espera «copiando».

+

organizador.py:1211-1229, 1452-1474, 2129, 1833, 2486

+
+ +
Medio
+

Consultas que escanean la tabla entera

+

archivos_bajo() usa LIKE sobre índice binario y hace full scan; se llama una vez por carpeta no reconocida y una vez por cada elemento movido. Mover 12.000 autoguardados de After Effects son 12.000 barridos de 25.000 filas. Duplicados y Mapa hacen os.path.exists por SMB en cada archivo de más de 5 MB.

+

organizador.py:242-247, 265, 949, 1694, 1059

+
+ +
Bajo
+

Otros

+
    +
  • El modo python_resolve apunta a un archivo que no se distribuye en el empaquetado.
  • +
  • El zip portátil lleva la lista completa de clientes, alias y mapa de unidades del estudio.
  • +
  • Los resolve_lote_*.json se acumulan en datos\; los bat generados llevan \r\r\n.
  • +
  • Endpoints muertos: /api/actualizacion/buscar, /api/duplicados GET, y modalTexto nunca conectado.
  • +
  • Sin pruebas automáticas; la prueba manual del README es anterior al cambio de nombre de la papelera, por eso se coló ese bug.
  • +
+

organizador.py:1400-1401, 2023, 2110 · empaquetar.py:47-57 · index.html:510

+
+ +

Método

+

Tres revisiones en paralelo sobre organizador.py, reglas.py, resolve_bridge.py, actualizador.py, empaquetar.py y static/index.html, con ejecución de las funciones de reglas contra casos límite y comprobación del contenido del zip de la app. Los hallazgos críticos se verificaron por segunda vez leyendo el código. Se revisó además la bitácora real de la copia portátil 1.4.1 en Descargas, que es la que se ha usado sobre el NAS.

+
diff --git a/Iniciar.bat b/Iniciar.bat new file mode 100644 index 0000000..0bbdb55 --- /dev/null +++ b/Iniciar.bat @@ -0,0 +1,10 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +where py >nul 2>nul +if %errorlevel%==0 ( + py -3.12 organizador.py || py -3 organizador.py +) else ( + python organizador.py +) +pause diff --git a/OrganizadorLabstream.spec b/OrganizadorLabstream.spec new file mode 100644 index 0000000..cab7250 --- /dev/null +++ b/OrganizadorLabstream.spec @@ -0,0 +1,56 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador'] +tmp_ret = collect_all('webview') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('clr_loader') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('pythonnet') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['organizador.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['tkinter'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='OrganizadorLabstream', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['static/organilab.ico'], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='OrganizadorLabstream', +) diff --git a/PLAN-INTERFAZ-2026-09-15.html b/PLAN-INTERFAZ-2026-09-15.html new file mode 100644 index 0000000..68dfb2a --- /dev/null +++ b/PLAN-INTERFAZ-2026-09-15.html @@ -0,0 +1,235 @@ +OrganiLAB, rediseño de interfaz + + +
+

OrganiLAB, rediseño de interfaz

+

Diagnóstico de la versión 1.4.3 recorriendo las ocho pantallas en vivo, puntos de mejora y plan por fases. 15 sep 2026.

+ +
+

El problema de fondo. La app está organizada por funciones internas (Plan, Nombres, Aligerar, Copia, Ajustes) y no por lo que una persona quiere hacer con una carpeta de cliente. Cada pestaña es una herramienta suelta, sin orden entre ellas, y ninguna dice en qué punto del proceso estás ni qué falta para poder seguir. El asistente paso a paso ya existe, pero solo se llega a él arrastrando la carpeta sobre el .exe: la interfaz principal no lo ofrece.

+
+ +

Lo que vi al recorrerla

+ +
+
Inicio con carpeta elegida dice «Elige una carpeta»David Reyes está seleccionado y hay inventario de 4.667 archivos, pero el título pide elegir, hay un recuadro gris vacío y dice «Escanea para ver el diagnóstico».
Es la primera pantalla y contradice el estado real. Ahí nace la confusión.
+
Ocho pestañas, cinco escondidas tras «Más»Inicio, Qué hay, Historial visibles. Nombres, Plan detallado, Aligerar a 10 bits, Copia de seguridad y Ajustes ocultas.
Lo que hace el trabajo de verdad (Plan, Aligerar) está oculto. Lo visible es lo que no actúa.
+
Nada dice el orden¿Primero Nombres o primero Plan? ¿Aligerar antes o después de organizar? ¿Copia de seguridad requiere haber aligerado?
El README explica un flujo de cinco pasos. La interfaz no lo refleja en ningún sitio.
+
Botones sin estado«Copiar» aparece verde sin haber calculado. «Aplicar seleccionadas» gris sin decir por qué. «Volver a escanear» sin saber si hace falta.
No hay forma de saber qué está habilitado, por qué, ni qué desbloquea.
+
Procesos largos sin superficieEscanear, analizar, convertir y copiar corren en segundo plano. Solo se ven si estás en la pestaña correcta en ese momento.
Es lo que describes como «no es visual qué hace». No hay barra global de tareas.
+
Vocabulario cruzadoRaíz, cliente, carpeta, lote, cubeta, confianza, contexto, rol, «Nombres que usamos», «Aligerar».
Términos del código llegan a pantalla. Una persona ve «raíz» y no sabe que es su carpeta de cliente.
+
Aligerar mezcla cuatro herramientas en una pantallaFiltros, analizar, candidatos, cola de conversión, verificar, reemplazar y debajo el stock de Envato.
Seis botones numerados del 1 al 5 más «Marcar todos» en un mismo bloque, sin separación de etapas.
+
El tour es texto en un modalOcho ventanas seguidas explicando cada pestaña la primera vez.
Se lee una vez y se olvida. La ayuda debe vivir en la pantalla, en el momento en que hace falta.
+
+ +

Principios para el rediseño

+
    +
  1. Una carpeta, un recorrido. La unidad de trabajo es la carpeta de cliente. Todo lo demás cuelga de ella y sigue un orden fijo: escanear, revisar, ordenar, aligerar, respaldar.
  2. +
  3. El estado siempre visible. En cada momento se ve en qué paso está esa carpeta, qué está hecho, qué está en curso y qué falta para el siguiente paso.
  4. +
  5. Un botón principal por pantalla. El resto son secundarios. Un botón deshabilitado siempre explica por qué al lado.
  6. +
  7. Las tareas largas tienen su propio sitio. Una barra de tareas global en la parte inferior muestra escaneos, análisis, conversiones y copias, con progreso y desde cualquier pantalla.
  8. +
  9. Hablar como en el estudio. «Carpeta del cliente» en vez de raíz. «Qué va a pasar» en vez de plan. «Convertir a 10 bits» en vez de aligerar. «Reglas de carpetas» en vez de nombres.
  10. +
+ +

Estructura propuesta

+
+ + + + + + + +
Hoy (8 pestañas)Propuesta (5 vistas)Qué cambia
InicioCarpeta · el recorridoPasa a ser el panel de estado y el camino de cinco pasos. Incluye el asistente paso a paso como opción principal.
Qué hay + Plan detallado + NombresRevisar y ordenarDiagnóstico arriba, plan abajo, en la misma vista. «Nombres» se convierte en un panel lateral que aparece solo para las carpetas no reconocidas que bloquean el plan.
Aligerar a 10 bits (con stock incluido)ConvertirTres etapas claras y separadas: elegir clips, convertir, verificar y reemplazar. El stock de Envato pasa a ser un paso del recorrido, no un apéndice.
Copia de seguridadRespaldarIgual, pero bloqueada con explicación hasta que haya conversiones verificadas.
HistorialHistorialSe mantiene. Se añade filtro por carpeta y el lote muestra «qué se hizo» en palabras, no solo rutas.
AjustesAjustes (icono de engranaje)Fuera de la navegación principal.
+ +

Cómo se vería la vista «Carpeta»

+
+ + + +Carpetas +David Reyes · 1,36 TB +Danney Gómez · 3,14 TB +Camilo Ortega · 3,10 TB ++ Añadir carpeta de cliente + +David Reyes +Z:\Dermatologas\David Reyes · escaneada hoy 10:17 · 4.667 archivos · 1,36 TB + +1 EscanearHecho hoyVolver a escanear +2 Revisar6 avisos, 301 basuraVer diagnóstico +3 OrdenarPlan listo: 42 movimientos4 carpetas por nombrar +4 Convertir723 GB en BRAWDespués de ordenar +5 RespaldarSin destinoRequiere paso 4 + + +Siguiente: ordenar la carpeta +El plan mueve 42 cosas dentro de la misma carpeta. Nada se borra: la basura y los proxies van a _Para borrar. +Antes de aplicar, dime qué son 4 carpetas que no reconozco: «Material», «Render», «Osmo», «Reels». +Nombrar las 4 carpetas +Ver el plan completo +Asistente paso a paso + + +TAREAS EN CURSO + +Analizando clips · 60 % · 12 min + +

Recorrido de cinco pasos con estado por paso, un bloque «Siguiente» con un solo botón principal, y una barra de tareas en curso siempre visible.

+
+ +

Puntos de mejora concretos

+ +

Estado y orientación

+
    +
  • Corregir Inicio: si hay carpeta seleccionada, el título es el nombre del cliente y la ruta. «Elige una carpeta» solo cuando no hay ninguna. Hoy la clave de la carpeta no se normaliza y por eso aparece «no escaneada» aunque lo esté.
  • +
  • Recorrido de cinco pasos con tres estados (hecho, en curso, pendiente) y el motivo cuando está bloqueado.
  • +
  • Bloque «Siguiente» que dice en una frase qué conviene hacer ahora y ofrece un botón principal.
  • +
  • Sidebar con el estado de cada carpeta de un vistazo (última acción, pendientes).
  • +
+ +

Tareas en curso

+
    +
  • Barra inferior global con cada tarea: qué es, sobre qué carpeta, porcentaje, tiempo estimado, botón de cancelar.
  • +
  • Al terminar, aviso no intrusivo con resultado y enlace («Se movieron 42 elementos. Ver en Historial»).
  • +
  • Mientras corre una tarea sobre una carpeta, los botones que la afectarían quedan deshabilitados con el motivo visible.
  • +
+ +

Acciones y botones

+
    +
  • Un botón principal por pantalla, en color. Secundarios en borde. Destructivos (mover a _Para borrar) con confirmación que resume «qué, cuánto y a dónde».
  • +
  • Botón deshabilitado siempre con texto al lado: «Calcula primero», «Requiere conversiones verificadas».
  • +
  • Quitar la numeración 1-5 de Aligerar y separar en tres bloques con título: Elegir clips, Convertir, Verificar y reemplazar.
  • +
  • Eliminar el doble clic accidental: deshabilitar mientras la petición está en vuelo.
  • +
+ +

Lenguaje

+
+ + + + + + + + +
HoyPropuesta
RaízCarpeta del cliente
Plan detallado / Generar planQué va a pasar / Calcular
Nombres que usamosReglas de carpetas (qué es cada carpeta)
Aligerar a 10 bitsConvertir a 10 bits
Confianza alta / media / bajaSeguro / Revisar / Dudoso
LoteCambio del 12 sep 5:35 (con «Deshacer»)
Cubeta, contexto, rolNo aparecen en pantalla
+ +

Tablas y datos

+
    +
  • Plan: agrupar por tipo de movimiento (basura, proxies, grabación, exportados) con conteo y peso por grupo, colapsados por defecto. Hoy son cientos de filas sueltas.
  • +
  • Historial: primera columna en palabras («Apartó 84 stock viejos», «Ordenó grabación de marzo») y las rutas al desplegar.
  • +
  • Qué hay: el mapa de peso sirve, pero las alertas deben enlazar al paso que las resuelve.
  • +
+ +

Ayuda

+
    +
  • Sustituir el tour de ocho modales por una línea de ayuda contextual bajo el título de cada vista, y un «¿Qué hace esto?» al lado de cada botón principal.
  • +
  • Mantener la frase «Nada se borra» visible junto a cualquier botón que mueva archivos.
  • +
+ +

Plan para seguir

+ +
Fase 01 a 2 días
+

Arreglos rápidos sin tocar la estructura. Cambian la percepción de inmediato.

+
    +
  • Inicio muestra el estado real de la carpeta seleccionada (normalizar claves de raíz).
  • +
  • Renombrar pestañas y textos según la tabla de lenguaje.
  • +
  • Botones deshabilitados con motivo al lado; deshabilitar durante peticiones en vuelo.
  • +
  • Manejo de errores en la interfaz: los fallos del servidor se muestran, no revientan la pantalla.
  • +
+ +
Fase 11 semana
+

La vista «Carpeta» con el recorrido de cinco pasos y el bloque «Siguiente». Es la pieza que resuelve la confusión.

+
    +
  • Definir el estado de cada paso a partir de los datos que ya tiene el servidor (escaneo, plan, cola, respaldo).
  • +
  • Nuevo endpoint /api/carpeta/estado que devuelva ese resumen de una vez.
  • +
  • Integrar el asistente paso a paso como botón desde la interfaz, no solo desde el .exe.
  • +
+ +
Fase 21 semana
+

Barra de tareas global.

+
    +
  • Un solo sondeo de estado que alimente la barra y todas las vistas, en vez de tres bucles paralelos.
  • +
  • Progreso, tiempo estimado y cancelar para escaneo, análisis, conversión y copia.
  • +
  • Avisos al terminar con enlace al resultado.
  • +
+ +
Fase 31 a 2 semanas
+

Consolidar las pestañas en las cinco vistas propuestas.

+
    +
  • «Revisar y ordenar»: diagnóstico y plan juntos, plan agrupado por tipo, reglas de carpetas como panel lateral.
  • +
  • «Convertir»: tres bloques separados; el stock de Envato como paso propio del recorrido.
  • +
  • Historial en palabras con filtro por carpeta.
  • +
+ +
En paralelodesde hoy
+

Los cuatro hallazgos críticos de la auditoría de código van antes o junto con la fase 1, porque el rediseño va a poner el «Convertir» y el «Aplicar» más a mano de más gente.

+
    +
  • Salidas de conversión sin colisión MP4/MOV.
  • +
  • Cerrar el API local (Host, token, claves permitidas en config).
  • +
  • No renombrar con «(2)»; reconocer el XML de Sony.
  • +
  • Contención de rutas dentro de la carpeta del cliente.
  • +
+ +

Cómo saber si funcionó

+
    +
  • Una persona nueva del estudio abre la app con una carpeta ya escaneada y dice en menos de un minuto qué paso toca y qué botón pulsar.
  • +
  • Nadie pregunta «¿está haciendo algo?» durante una conversión: la barra lo dice.
  • +
  • Ningún botón gris sin explicación.
  • +
  • Cero clics en «Más» para hacer el trabajo diario.
  • +
+
diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6c6209 --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +# Organizador Labstream + +App local (Python, sin dependencias) para **inventariar, mapear, ordenar y optimizar** el material de los +clientes de Labstream repartido por los discos del estudio. + +``` +Iniciar.bat ← doble clic. Abre http://127.0.0.1:8765 en el navegador +organizador.py ← servidor + inventario + plan + cola de conversión +reglas.py ← cómo reconoce cámaras, carpetas, basura y las plantillas de estructura +resolve_bridge.py ← convierte BRAW a H.265 10 bits usando DaVinci Resolve Studio +static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Convertir · Respaldar · Historial · Ajustes) +datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl +``` + +Nunca borra nada. Todo lo que sobra va a `_Para borrar` dentro de la misma raíz y cada movimiento queda en la +bitácora para poder deshacerlo. + +--- + +## 1. Diagnóstico (9 sep 2026) + +| Raíz | Archivos | Peso | BRAW | Stock Envato | Proxies | Basura | Duplicados | +|---|---|---|---|---|---|---|---| +| `Z:\Dermatologas\David Reyes` | 4 667 | 1,36 TB | 723 GB (53 %) | 464 GB | 4,5 GB | 301 archivos | 91 GB | +| `W:\Danney` | 24 967 | 3,14 TB | 1,18 TB (40 %) | — | — | 12 000 autoguardados de AE | — | +| `N:\` (Camilo Ortega) | 24 016 | 3,10 TB | — | — | 150 GB | 1 031 archivos | — | + +Lo que se repite en todas: + +- **El mismo cliente vive en hasta 12 sitios.** Camilo Ortega aparece en N:, K:, L:, M:, Q: y Z:. David Reyes + en Z:, K:, Q: y V:. Danney en W:, K: y Q:. Cada sitio con un nombre distinto (`David Reyes`, `DR. DAVID REYES`). +- **Dos sistemas de orden a la vez.** Danney tiene `2026/MM- Mes/NN- Pieza` y, al lado, `Nuevo Orden/` por + cámara y año con 1,8 TB. El mismo tipo de material está en los dos. +- **Los meses no se llaman igual:** `08-Agosto`, `09- Septiembre`, `10- octubre`, `02-marzo` (que es marzo + pero numerado 02), `04 -Mayo`, `05- MAYO`. +- **Material suelto en la raíz:** PSD de 2 GB, archivos «Recuperado», capturas de pantalla, un MP4 final. +- **Intermedios enormes que ya no hacen falta:** ProRes HQ vertical de 8 GB, prerenders QuickTime Animation + de 10 GB, proxies de Resolve/Premiere al lado del original. +- **Stock de Envato copiado dentro de cada pieza** (`Visual/Elements`): 464 GB en David Reyes, con + 91 GB duplicados exactos. +- **Basura de sistema por todas partes:** `.DS_Store`, `Thumbs.db`, `.tmp` de Photoshop (uno de 1,2 GB), + `~lock~`, carpetas de autoguardado de After Effects. + +## 2. Cómo organizarlo + +### Cuatro tipos de cliente, cuatro plantillas + +| Tipo | Quiénes | Estructura | +|---|---|---| +| **contenido** (mensual) | dermatólogas, marca personal | `AAAA / MM- Mes / NN- Pieza` + `00_Grabacion` por mes | +| **pauta** (anuncios) | Camilo Ortega, Jonathan, Malpraxis | `Campañas / AAAA_Campaña` + `Material / Grabaciones, Celular, IA` | +| **proyecto** (puntual) | Brigard, PepsiCo, Kingspan, Filarmónica… | `00_Brief 01_Material 02_Grabacion 03_Proyecto 04_Exportados` | +| **interno** | recursos de Labstream | `Musica Sonidos Stock LUTs Transiciones` compartidos | + +### La carpeta canónica de un cliente de contenido + +``` +David Reyes/ +├── 00_Marca/ logos, manual, fuentes, fondos, transiciones, intros +├── 01_Proyectos/ .drp .prproj .aep .psd de trabajo (Diseño/, Edicion/) +├── 2026/ +│ ├── 03- Marzo/ +│ │ ├── 00_Grabacion/ originales de cámara del mes, separados por fuente +│ │ │ ├── URSA_BRAW/ ├── SONY/ ├── OSMO/ ├── CELULAR/ └── AUDIO/ +│ │ ├── 01- La isotretinoina/ ← una pieza: Guion, Visual, Audio, Portada, Export +│ │ ├── 02- El poder del ácido hialurónico/ +│ │ ├── _Exportados/ masters del mes +│ │ └── _Portadas/ +│ └── 08- Agosto/ +├── Campañas/2026_Garnier/ trabajos de marca (Dove, Loreal, Isdin…) +├── _Apoyos/ tomas de apoyo reutilizables por tema +├── _Recursos/Musica Stock (mejor aún: en la biblioteca compartida del estudio) +├── _Documentos/ propuestas, referentes, brief +├── _Revisar/ lo que la app no supo ubicar +└── _Para borrar/ basura, proxies y originales ya convertidos (la app no borra; lo revisa y lo borra una persona) +``` + +Reglas que hacen que funcione: + +1. **Un cliente, una carpeta, un disco.** Las únicas otras dos casas legítimas son `Entregas_LAB` (K:) y + `Exportados` (Q:), que ya gestiona Labstream OS. +2. **Los archivos no se renombran nunca; solo se mueven carpetas.** Resolve y Premiere re-enlazan por nombre. +3. **Dentro de una pieza no se toca.** El orden es de la raíz hasta el nivel de pieza. +4. **El mes es el de publicación, no el de rodaje.** Una `Grabación` de diciembre que alimenta piezas de marzo + vive en `03- Marzo/00_Grabacion`. Cuando no hay mes, la app usa la fecha del nombre del archivo + (Sony, DJI, Higgsfield, Envato y el MMDD de los clips Blackmagic) y solo al final la de modificación. +5. **Stock y música van a la biblioteca compartida**, no dentro de cada pieza. +6. **Los proxies se regeneran**: a `_Para borrar/proxies`. La basura de sistema, a `_Para borrar/basura`. +7. **Política de frío.** El BRAW de piezas ya publicadas y con más de 3 meses se convierte a H.265 10 bits; + el original va a `_Para borrar/originales`; una persona lo borra cuando comprueba que nadie lo echó de menos. + +## 3. Cómo la app aprende + +- **Diccionario.** La pestaña lista cada nombre de carpeta que aparece en la raíz («Grabado», «Render», + «Julio music», «Nuevo Orden»…) con su peso. Le dices qué es (grabación, exportados, portadas, marca…) y + desde entonces esa palabra se reconoce en cualquier disco. Viene sembrado con ~200 palabras vistas en las + carpetas actuales, incluidos los errores de tecleo (`Elemnts`, `Visula`, `Calular pero de`). +- **Reglas exactas.** Si en el plan corriges el destino de una carpeta, se guarda como regla para ese cliente. +- **Alias de clientes.** `DR. DAVID REYES` = `David Reyes` = `Davids`. Con ellos la pestaña Mapa cruza todos + los discos y dice dónde vive cada cliente. +- Todo está en `datos/diccionario.json` y `datos/config.json`: texto plano, se puede editar a mano o versionar. + +## 4. Optimizar material viejo (MP4 H.265 10 bits) + +| Origen | Motor | Resultado medido | +|---|---|---| +| Sony XAVC-I 4K 60p 4:2:2 10 bits, 228 Mb/s | ffmpeg + NVENC (RTX 5080) | 671 MB → 24,6 MB (8,4 Mb/s) en 30 s, HEVC Main10 | +| Sony XAVC-I 4K 30p, 286 Mb/s | ffmpeg + NVENC | 286 → 9,6 Mb/s, 1,5× tiempo real | +| BRAW URSA 4.6K 30p, 567 Mb/s | DaVinci Resolve Studio (H.265 NVIDIA, Main10) | ≈ 31 Mb/s → unas 18 veces menos | + +- **Perfiles:** ahorro / equilibrado / máxima. El bitrate objetivo sale del tamaño del cuadro y los fps + (≈ 0,085 bits por píxel y cuadro en «equilibrado»: 4K30 → 21 Mb/s, 4.6K30 → 31 Mb/s). +- **Croma:** 4:2:0 10 bits (Main10, se reproduce en todo) o 4:2:2 10 bits (Rext; la RTX 5080 lo codifica y + Resolve lo lee, pero muchos reproductores no). +- **BRAW:** pasa por Resolve porque ffmpeg no lo decodifica. El puente abre (o crea) el proyecto + «Labstream Archivo» con gestión de color a Rec.709 Gamma 2.4, importa los clips, monta una línea de + tiempo por resolución y renderiza «clips individuales». Si Resolve no deja cambiar de proyecto, trabaja en + un bin dentro del proyecto abierto y aplica la LUT Gen 5 Film → Video a cada clip. **Resolve debe estar + abierto con un proyecto, no en el Gestor de proyectos, y sin diálogos abiertos.** +- **Flujo:** Analizar (ffprobe) → Ver candidatos → Encolar → Verificar (duración, códec, 10 bits) → + Reemplazar (el original y su `.XML` de Sony van a `_Para borrar/originales`). La salida conserva la fecha + de modificación del original y se llama `nombre_10bit.mp4` junto al original. +- Se excluyen solos: lo que ya está en HEVC/AV1 eficiente (los iPhone en HDR, por ejemplo), lo que tiene + canal alfa, los exportados/masters (salvo que se marque «incluir exportados») y lo más reciente que N meses. + +Estimación con los inventarios actuales: los 1,9 TB de BRAW de Danney y David Reyes quedarían en unos +110 GB; los intermedios ProRes/XAVC de más de 1 GB (≈ 1 TB entre los dos) en menos de 100 GB. + +### Stock de Envato en proyectos viejos + +Las tomas de Envato se vuelven a descargar con la suscripción: en piezas ya publicadas sobran. En la +pestaña Optimizar, «Buscar stock viejo» lista, agrupado por carpeta, todo lo que la app reconoce como +Envato (nombre `…-2026-01-22-22-34-00-utc` o carpeta `Elements`) en piezas de más de N meses. Se elige +video, música o imágenes. Va a `_Para borrar/stock` (reversible desde Bitácora); la app no lo borra nunca, eso lo hace una persona desde el Explorador. En David Reyes son 464 GB. + +### Respaldo compacto (pestaña Respaldo) + +Copia a otro disco una versión ligera de cada cliente para tenerla a mano: + +- misma estructura de carpetas (`AAAA / MM- Mes / NN- Pieza`), y donde una carpeta mezcla tipos de medio se + separa en `Video`, `Audio`, `Imagenes`, `Diseño`, `Proyectos`, `Documentos`, `Fuentes`; +- los clips de cámara van en su versión **MP4 H.265 10 bits** (verificada) en lugar del original; + el BRAW/RAW que aún no se convirtió no se copia, y la pestaña lo lista para que se convierta antes; +- sin stock de Envato, sin proxies, sin basura, sin `_Para borrar`; +- deja `INDICE.csv` (ruta en el respaldo, ruta original, tipo, bytes, fecha), `_MAPA.md` (resumen por + carpeta y tipo) y `manifest.json` en la raíz del respaldo; +- es incremental: lo que ya está igual (tamaño y fecha) se salta, así se puede detener y retomar. + +Orden recomendado por cliente: Ordenar → Optimizar (convertir BRAW e intermedios, verificar, reemplazar) +→ Buscar stock viejo → Respaldo → cuando el respaldo esté comprobado, una persona borra `_Para borrar` a mano. + +## 5. Los discos + +15 recursos compartidos en tres cajas (NAS Labstream 192.168.0.22, LabTem 192.168.0.40 y el Blackmagic +Cloud Store Mini 192.168.0.3). La pestaña Mapa los lista con su uso y marca en azul las carpetas que +reconoce como clientes. Propuesta de niveles: + +| Nivel | Dónde | Qué | +|---|---|---| +| caliente | Cloud Store Mini (10G) | el mes en edición | +| trabajo | NAS Labstream (`labstream`, `Camilo Ortega`, `Medicina1`…) | el año en curso, una carpeta por cliente | +| entregas | `Entregas_LAB` y `Exportados` | lo maneja Labstream OS | +| frío | `temple`, `Datos`, `Nubes` | años anteriores ya optimizados a H.265 10 bits | +| recursos | `Recursos Audiovisuales`, `Sonidos` | música, stock, LUTs, transiciones compartidos | + +Consolidar un cliente entre discos es una copia grande; conviene hacerla en el propio NAS +(`rsync` por SSH) y no desde el PC. La app ordena **dentro** de cada raíz; el cruce entre discos lo +muestra en el mapa para decidirlo. + +## 6. Uso (interfaz 1.6) + +1. `Iniciar.bat` (o `py -3.12 organizador.py`). Requiere Python 3.10+, ffmpeg/ffprobe en el PATH y, para BRAW, + DaVinci Resolve Studio abierto con scripting externo en *Local*. +2. Suelta la carpeta del cliente en la barra lateral (o pega la ruta). Se registra y se escanea sola. +3. La vista **Carpeta** es el recorrido: cinco pasos con estado (hecho, en curso, listo, pendiente, bloqueado) + y un bloque «Siguiente» que dice qué toca y ofrece un solo botón principal. El **Asistente paso a paso** + sigue disponible desde ahí. + - **1 Escanear**: solo lee nombres y tamaños. + - **2 Revisar**: diagnóstico y carpetas que la app no reconoce (se nombran en un panel, al lado del plan). + - **3 Ordenar**: «Calcular» enseña qué va a pasar, agrupado por tipo (basura, grabación, piezas…), con + seguridad *seguro / revisar / dudoso*. «Aplicar» mueve dentro de la misma carpeta. Se deshace desde Historial. + - **4 Convertir a 10 bits**: tres bloques en orden: elegir clips, conversión en curso, verificar y reemplazar. + Abajo, el stock de Envato viejo. + - **5 Respaldar**: destino → Calcular → Copiar. Avisa si hay BRAW sin convertir. +4. **Historial**: cada cambio en palabras («Ordenó carpetas», «Apartó stock viejo») con su Deshacer, y lo que + hay en `_Para borrar`. +5. **Ajustes** (engranaje): carpetas registradas, clientes con alias, plantillas. + +Las tareas largas (escaneo, lectura de clips, conversión, asistente, copia) se ven en la **barra inferior** desde +cualquier vista, con progreso y Detener; al terminar aparece un aviso. Un botón deshabilitado siempre dice al +lado por qué. Los errores del programa se muestran como aviso, no rompen la pantalla. + +Cambios de seguridad de 1.5.0: el servidor local rechaza peticiones que no vengan de la propia página +(cabeceras Host/Origin y Content-Type), `/api/config` solo acepta ajustes de conversión y respaldo, ningún +destino puede salir de la carpeta del cliente, la carpeta `_Para borrar` ya no se vuelve a planificar como +basura, y no se encola una conversión cuya salida ya exista o esté reclamada por otro clip (MP4 y MOV del +mismo nombre). El plan se invalida al re-escanear. + +Movimientos (1.5.0): si en el destino ya existe un archivo con el mismo nombre, **no se renombra con «(2)»**: +se deja donde está y sale en los errores del lote (Resolve y Premiere enlazan por nombre, y el XML de Sony +va emparejado con su clip). Solo dentro de `_Para borrar` y `Duplicados` se numera. Si un archivo está +bloqueado (abierto en un editor) o la copia entre discos queda incompleta, el original no se toca. El sidecar +Sony `C0001M01.XML` se reconoce como cámara. + +Actualización automática (1.5.0): sigue funcionando igual para el usuario (aviso en la vista Carpeta → +«Actualizar ahora»), pero antes de instalar se exige que el manifiesto traiga versión con forma `1.2.3`, +nombre de zip simple, sha256 obligatorio y descarga en el mismo servidor https que el manifiesto. No se +actualiza con un escaneo, lectura, conversión, asistente o copia en marcha. El `.bat` espera a que se cierren +todas las ventanas de OrganiLAB, guarda copia de la versión actual en `actualizacion\previa`, instala y, si +la copia falla, restaura la anterior. `datos\` y `ffmpeg\` nunca se tocan. + +1.5.1: los hilos de lectura de clips y de la cola de conversión ya no dejan la app «ocupada» si fallan; cada +«Reemplazar» es un lote propio (`optimizar-fecha`) que se deshace solo, y antes de mover el original comprueba +que el MP4 verificado siga ahí; «Deshacer» dice qué archivos no pudo devolver y por qué; una carpeta llamada +solo «3» bajo un año se propone como marzo con confianza media, no alta; el puente con Resolve, cuando no puede +abrir «Labstream Archivo» y usa el proyecto del editor, no toca sus ajustes ni su cola de render ni lo guarda, +y nunca pisa un MP4 que ya exista; las raíces de unidad (`N:\`) ya se consultan bien (mapa y respaldo). + +### Publicar una versión + + py -3.12 empaquetar.py # construye dist\, los dos zips y manifiesto.json (NOTAS="..." para las notas) + bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS + +Cuando el OS sirve el manifiesto nuevo, cada copia instalada lo ve al abrirse (o al pulsar «Actualizar ahora»), +descarga el zip «app» (15 MB), comprueba el sha256 y se sustituye sola sin tocar `datos\` ni `ffmpeg\`. + +1.5.2: «Juntar repetidos» comprueba el contenido (primer MB, centro y último) antes de apartar una copia +«idéntica»; si difiere, la deja y lo dice. El plan avisa cuando el destino ya tiene un archivo con ese nombre +(baja a «dudoso» y no se marca). El actualizador reintenta solo durante unos minutos si el OS responde 502/503 +(pasa mientras se redespliega) en vez de fallar a la primera. + +1.5.3: el recorrido de cinco pasos va fijo arriba en todas las vistas (con el paso de la vista actual +resaltado, «Volver a escanear» y el asistente siempre a mano; el paso 1 también re-escanea al hacer clic); +la página aprovecha pantallas anchas (hasta 1720 px); `_Para borrar`, `_Papelera` y `Duplicados` se muestran +como «apartado por la app», no como carpetas sin reconocer. + +1.5.4: «Convertir a 10 bits» queda en tres pasos limpios: Buscar clips (lee y lista en un solo botón; los +criterios y la calidad están plegados en «Cambiar criterios»), Convertir (lista compacta con estado, sin +tabla de diez columnas) y Reemplazar. Cada clip se verifica solo al terminar, así que el paso «Verificar» +desaparece. El stock de Envato pasa a «Revisar y ordenar». Ajustes es una sola lista por cliente: tipo, +sus carpetas y otros nombres con que aparece; las plantillas quedan en «Avanzado». Confirmado que ffprobe +sí lee los BRAW (resolución, fps, duración), así que la vía Resolve es alcanzable desde la interfaz. + +1.5.5: botón «Buscar actualizaciones» en la barra lateral (junto a la versión y la hora de la última +comprobación). Por comando, en PowerShell hay que dar la ruta completa del .exe con `&` (no está en el PATH): + + & "C:\Users\Munka\Downloads\OrganizadorLabstream-portatil-1.4.1\OrganizadorLabstream\OrganizadorLabstream.exe" actualizar --si + +(`--si` instala sin preguntar; el nombre de la carpeta portátil no cambia al actualizar, solo su contenido). + +1.6.0, rediseño: el recorrido de cinco pasos vive en la barra lateral, colgando de la carpeta seleccionada +(punto de estado, dato corto, clic para ir al paso; el paso 1 re-escanea). Cada vista cabe en una pantalla: +«Revisar y ordenar» tiene pestañas internas (Qué hay · Qué va a pasar · Por nombrar con su número · Stock +viejo); «Convertir» es un solo panel con tres botones (Buscar clips, Convertir N, Reemplazar N) y una lista +única con el estado de cada clip; «Historial» es una línea de tiempo por día con lo apartado en _Para borrar +resumido arriba; «Ajustes» son tarjetas de cliente plegadas que se abren para editar. La ayuda queda en una +línea y un «?» despliega el resto. Texto 13,5 px, un solo botón lleno por pantalla, y la carpeta que está +trabajando parpadea en la barra lateral. + +1.6.1: se puede desconectar una carpeta escaneada. Aparece una ✕ al pasar el ratón por la carpeta en la +barra lateral y un enlace «Desconectar» en la cabecera de «Carpeta». Solo la quita de la lista: no borra ni +mueve nada del disco. Una casilla opcional olvida también el inventario escaneado (si no se marca, al volver +a soltar la carpeta el escaneo anterior sigue ahí). No deja desconectar mientras esa carpeta se escanea, +se analiza, se le aplica el plan o tiene conversiones pendientes. Endpoint `POST /api/raiz/quitar +{raiz, datos}`; queda anotado en la bitácora como `raiz_quitada`. + +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: +queda por probar con un proyecto abierto. diff --git a/actualizador.py b/actualizador.py new file mode 100644 index 0000000..9f30d89 --- /dev/null +++ b/actualizador.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- +r""" +Actualización automática desde Labstream OS. + +El OS publica public/herramientas/organizador.json (versión, archivo del paquete «app», tamaño, +sha256). Este módulo lo consulta, descarga el zip nuevo a BASE\actualizacion\, comprueba el +sha256, lo descomprime y deja un actualizar.bat que, cuando el .exe se cierra, guarda una copia +de la versión actual, copia la nueva encima (sin tocar datos\ ni ffmpeg\), vuelve atrás si algo +falla, y reabre el programa. + +Lo que se exige del manifiesto para instalar (si no se cumple, no se instala y se explica por qué): + - versión con forma 1.2.3 + - nombre de archivo simple (sin rutas) terminado en .zip + - sha256 de 64 hex, obligatorio + - URL de descarga con el mismo esquema y host que el manifiesto (nada de saltar a otro servidor) + +`O` es el módulo organizador (lo fija él al arrancar). +""" +import os +import re +import sys +import json +import time +import shutil +import hashlib +import zipfile +import tempfile +import threading +import subprocess +import urllib.error +import urllib.request +from urllib.parse import urljoin, urlparse + +O = None +URL_DEFECTO = "https://os.labstreamsas.com/herramientas/organizador.json" +ESTADO = {"version": "", "comprobado": 0, "disponible": None, "estado": "", "pct": 0, "bytes": 0, "total": 0, "error": "", + "aplicando": False} +_LOCK = threading.Lock() +RE_VERSION = re.compile(r"^\d{1,4}(\.\d{1,5}){0,3}$") +RE_SHA = re.compile(r"^[0-9a-fA-F]{64}$") + + +def _cfg(): + c = O.CONFIG.setdefault("actualizaciones", {}) + c.setdefault("url", URL_DEFECTO) + c.setdefault("auto", True) + return c + + +def _tupla(v): + out = [] + for p in str(v or "0").split("."): + num = "".join(ch for ch in p if ch.isdigit()) + out.append(int(num) if num else 0) + return tuple(out + [0] * (3 - len(out))) + + +def _validar(m, url_manifiesto): + """Devuelve (dict disponible, "") o (None, motivo). Solo acepta manifiestos bien formados.""" + app = m.get("app") or {} + version = str(m.get("version") or "") + if not RE_VERSION.match(version): + return None, f"versión con formato raro en el manifiesto: {version!r}" + archivo = str(app.get("archivo") or "") + if not archivo or archivo != os.path.basename(archivo) or not archivo.lower().endswith(".zip") or "/" in archivo or "\\" in archivo: + return None, f"nombre de archivo no válido en el manifiesto: {archivo!r}" + sha = str(app.get("sha256") or "") + if not RE_SHA.match(sha): + return None, "el manifiesto no trae el sha256 del paquete; no se instala nada sin él" + url = urljoin(url_manifiesto, app.get("url") or f"/api/herramientas/{archivo}") + a, b = urlparse(url_manifiesto), urlparse(url) + if (a.scheme, a.netloc.lower()) != (b.scheme, b.netloc.lower()): + return None, f"la descarga apunta a otro servidor ({b.netloc}); solo se acepta {a.netloc}" + if a.scheme != "https" and not a.netloc.split(":")[0] in ("127.0.0.1", "localhost"): + return None, "el manifiesto debe servirse por https" + return {"version": version, "notes": str(m.get("notes", ""))[:2000], "archivo": archivo, + "size": int(app.get("size") or 0), "sha256": sha.lower(), "url": url}, "" + + +def comprobar(timeout=8): + """Lee el manifiesto del OS. Devuelve el dict de la versión disponible o None.""" + ESTADO["version"] = O.VERSION + url = _cfg()["url"] + try: + req = urllib.request.Request(url, headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}", "Cache-Control": "no-cache"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + m = json.loads(r.read(1 << 20).decode("utf-8")) + ESTADO["error"] = "" + except Exception as ex: # noqa + ESTADO["error"] = f"no se pudo consultar {url}: {ex}" + ESTADO["comprobado"] = time.time() + return None + ESTADO["comprobado"] = time.time() + d, motivo = _validar(m, url) + if d and _tupla(d["version"]) > _tupla(O.VERSION): + ESTADO["disponible"] = d + else: + ESTADO["disponible"] = None + if not d and _tupla(m.get("version")) > _tupla(O.VERSION): + ESTADO["error"] = "hay una versión publicada pero el manifiesto no pasa las comprobaciones: " + motivo + return ESTADO["disponible"] + + +def comprobar_en_segundo_plano(): + threading.Thread(target=comprobar, daemon=True).start() + + +def _sha256(ruta): + h = hashlib.sha256() + with open(ruta, "rb") as fh: + for b in iter(lambda: fh.read(1 << 20), b""): + h.update(b) + return h.hexdigest() + + +def tarea_en_marcha(): + """Texto de la tarea que impide actualizar ahora, o '' si se puede.""" + if O.COLA.get("activa"): + return "hay una conversión en marcha" + if O.RESPALDO.get("estado") == "corriendo": + return "hay una copia de respaldo en marcha" + if O.asistente.ESTADO.get("activo"): + return "el asistente está trabajando" + if any((p or {}).get("estado") == "corriendo" for p in O.PROGRESO.values()): + return "hay un escaneo en marcha" + if any((a or {}).get("estado") == "corriendo" for a in O.ANALISIS.values()): + return "se están leyendo clips" + return "" + + +def _bat(s): + """escapa lo que cmd interpretaría dentro del .bat""" + return str(s).replace("%", "%%") + + +def descargar_y_aplicar(en_hilo=False, relanzar_args=()): + """Descarga el paquete, lo verifica, lo descomprime y deja el .bat listo; luego cierra el programa.""" + if en_hilo: + if _LOCK.locked(): + ESTADO["error"] = "ya hay una actualización en curso" + return False + threading.Thread(target=descargar_y_aplicar, args=(False, relanzar_args), daemon=True).start() + return True + if not _LOCK.acquire(blocking=False): + ESTADO["error"] = "ya hay una actualización en curso" + return False + try: + return _descargar_y_aplicar(relanzar_args) + finally: + if ESTADO["estado"] != "reiniciando": + _LOCK.release() + + +def _descargar_y_aplicar(relanzar_args): + d = ESTADO.get("disponible") or comprobar() + if not d: + ESTADO["estado"] = "al día" + return False + if not O.CONGELADO: + ESTADO["estado"] = "error" + ESTADO["error"] = "la actualización automática solo funciona con el .exe empaquetado" + return False + ocupado = tarea_en_marcha() + if ocupado: + ESTADO["estado"] = "error" + ESTADO["error"] = f"no se actualiza ahora: {ocupado}. Espera a que termine." + return False + carpeta = os.path.join(O.BASE, "actualizacion") + try: + shutil.rmtree(carpeta, ignore_errors=True) + os.makedirs(carpeta, exist_ok=True) + destino = os.path.join(carpeta, os.path.basename(d["archivo"])) + ESTADO.update(estado="descargando", pct=0, bytes=0, total=d.get("size", 0), error="") + req = urllib.request.Request(d["url"], headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}"}) + tope = max(int(d.get("size") or 0) * 2, 64 << 20) # nunca más del doble de lo anunciado + r = None + for intento in range(1, 7): + try: + r = urllib.request.urlopen(req, timeout=30) + break + except urllib.error.HTTPError as ex: + # 502/503/504: el OS se está redesplegando (pasa unos minutos cada vez que se publica algo) + if ex.code not in (502, 503, 504) or intento == 6: + raise RuntimeError("el servidor de Labstream OS está reiniciándose; prueba de nuevo en un par de minutos" if ex.code in (502, 503, 504) else str(ex)) + ESTADO["error"] = f"el servidor está ocupado (HTTP {ex.code}); reintento {intento} de 6 en 20 s…" + time.sleep(20) + except urllib.error.URLError as ex: + if intento == 6: + raise RuntimeError(f"no se pudo conectar con Labstream OS: {ex.reason}") + ESTADO["error"] = f"sin conexión con el servidor; reintento {intento} de 6 en 20 s…" + time.sleep(20) + ESTADO["error"] = "" + with r, open(destino, "wb") as fh: + total = int(r.headers.get("Content-Length") or d.get("size") or 0) + ESTADO["total"] = total + leidos = 0 + while True: + b = r.read(1 << 18) + if not b: + break + fh.write(b) + leidos += len(b) + if leidos > tope: + raise RuntimeError("la descarga es mucho más grande de lo anunciado; se cancela") + ESTADO["bytes"] = leidos + ESTADO["pct"] = int(leidos * 100 / total) if total else 0 + ESTADO["estado"] = "verificando" + if _sha256(destino).lower() != d["sha256"]: + raise RuntimeError("el archivo descargado no coincide con el sha256 publicado; no se instala") + nueva = os.path.join(carpeta, "nueva") + os.makedirs(nueva) + with zipfile.ZipFile(destino) as z: + nombres = z.namelist() + # el zip lleva la carpeta OrganizadorLabstream/ como raíz: se quita ese primer nivel + raiz = nombres[0].split("/")[0] + "/" if nombres and "/" in nombres[0] and all(n.startswith(nombres[0].split("/")[0] + "/") for n in nombres) else "" + for n in nombres: + rel = n[len(raiz):] + if not rel or rel.endswith("/"): + continue + dest = os.path.normpath(os.path.join(nueva, rel)) + if not dest.startswith(os.path.normpath(nueva) + os.sep): + continue + os.makedirs(os.path.dirname(dest), exist_ok=True) + with z.open(n) as src, open(dest, "wb") as out: + shutil.copyfileobj(src, out) + if not os.path.exists(os.path.join(nueva, "OrganizadorLabstream.exe")): + raise RuntimeError("el paquete no trae OrganizadorLabstream.exe") + exe = os.path.join(O.BASE, "OrganizadorLabstream.exe") + previa = os.path.join(carpeta, "previa") + # relanzar_args None = no volver a abrir (modo consola); tupla = abrir con esos argumentos + args = " ".join(f'"{_bat(a)}"' for a in (relanzar_args or ())) + linea_start = f'start "" "{_bat(exe)}" {args}\r\n' if relanzar_args is not None else f"echo Listo: version {d['version']} instalada.\r\n" + # el .bat vive FUERA de la carpeta que va a borrar; UTF-8 con chcp 65001 para rutas con acentos + bat = os.path.join(tempfile.gettempdir(), "organizador_actualizar.bat") + excl = "/XD datos ffmpeg actualizacion" + with open(bat, "w", encoding="utf-8", newline="") as fh: + fh.write("@echo off\r\n" + "chcp 65001 >nul\r\n" + f"title Actualizando OrganiLAB a {d['version']}\r\n" + "echo Esperando a que se cierre OrganiLAB...\r\n" + ":espera\r\n" + f"tasklist /FI \"PID eq {os.getpid()}\" 2>nul | find \"{os.getpid()}\" >nul && (timeout /t 1 /nobreak >nul & goto espera)\r\n" + ":espera2\r\n" + "tasklist /FI \"IMAGENAME eq OrganizadorLabstream.exe\" 2>nul | find /I \"OrganizadorLabstream.exe\" >nul && (echo Cierra las otras ventanas de OrganiLAB para seguir... & timeout /t 2 /nobreak >nul & goto espera2)\r\n" + "echo Guardando copia de la version actual...\r\n" + f"robocopy \"{_bat(O.BASE)}\" \"{_bat(previa)}\" /E /R:2 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n" + "if %ERRORLEVEL% GEQ 8 (echo ERROR: no se pudo guardar la copia de seguridad. No se cambia nada. & pause & exit /b 1)\r\n" + f"echo Instalando la version {d['version']}...\r\n" + f"robocopy \"{_bat(nueva)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n" + "if %ERRORLEVEL% GEQ 8 (\r\n" + " echo ERROR al copiar la version nueva. Volviendo a la anterior...\r\n" + f" robocopy \"{_bat(previa)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n" + " echo Restaurada la version anterior. Avisa a Labstream.\r\n" + " pause & exit /b 1\r\n" + ")\r\n" + + linea_start + + 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"] = "reiniciando" + ESTADO["aplicando"] = True + # con su PROPIA consola: sin ella, timeout/tasklist se quedan colgados y la copia nunca ocurre + flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) + subprocess.Popen(["cmd", "/c", bat], creationflags=flags, close_fds=True, cwd=tempfile.gettempdir()) + threading.Timer(1.5, lambda: os._exit(0)).start() + return True + except Exception as ex: # noqa + ESTADO["estado"] = "error" + ESTADO["error"] = str(ex) + ESTADO["aplicando"] = False + return False + + +def cli(args): + print(f"Versión instalada: {O.VERSION}. Consultando {_cfg()['url']} …") + d = comprobar() + if ESTADO["error"]: + print("No se pudo comprobar:", ESTADO["error"]) + return 1 + if not d: + print("Ya tienes la última versión.") + return 0 + print(f"Hay versión nueva: {d['version']} ({(d.get('size') or 0) / 1e6:.0f} MB)") + if d.get("notes"): + print(" " + d["notes"]) + if "--si" not in args and not O._confirmar("¿Descargar e instalar ahora? El programa se cerrará y volverá a abrirse", False): + return 0 + hilo = threading.Thread(target=descargar_y_aplicar, args=(False, None), daemon=True) + hilo.start() + ultimo = -1 + while hilo.is_alive(): + if ESTADO["estado"] == "descargando" and ESTADO["pct"] != ultimo: + ultimo = ESTADO["pct"] + sys.stdout.write(f"\r descargando {ultimo:3d}% {ESTADO['bytes'] / 1e6:.0f}/{ESTADO['total'] / 1e6:.0f} MB") + sys.stdout.flush() + time.sleep(0.2) + print() + if ESTADO["estado"] == "error": + print("ERROR:", ESTADO["error"]) + return 1 + print("Instalando… se abre una ventana «Actualizando» que termina sola.", flush=True) + time.sleep(3) + return 0 diff --git a/asistente.py b/asistente.py new file mode 100644 index 0000000..5b85016 --- /dev/null +++ b/asistente.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +""" +Asistente: al añadir una carpeta la escanea, da un diagnóstico corto por tipo de archivo y +pregunta, paso a paso, qué hacer con cada tipo. Sirve tanto a la consola (arrastrar la carpeta +sobre el .exe) como a la interfaz web (/api/diagnostico, /api/asistente). + +`O` es el módulo organizador (lo fija él mismo al arrancar) para compartir CONFIG, base de datos, +Plan, aplicar, cola de conversión, etc. sin importarlo dos veces. +""" +import os +import sys +import time +import threading + +O = None + +# ---------------------------------------------------------------- diagnóstico +GRUPOS = [ + # clave, título corto, qué es (una frase) + ("camara_raw", "Clips BRAW (cámara URSA / Blackmagic)", "material original de cámara en 12 bits; lo que más pesa"), + ("camara_video", "Clips de cámara MP4/MOV (Sony, Osmo, DJI, Atomos)", "material original de cámara, normalmente 10 bits"), + ("audio", "Audio grabado (DJI Mic, Tascam) y música", "pistas de voz, ambiente y música"), + ("celular", "Celular y WhatsApp", "videos y fotos del teléfono"), + ("fotos", "Fotos de cámara", "JPG/RAW de cámara fotográfica"), + ("stock_viejo", "Stock de Envato en proyectos viejos", "tomas descargadas de Envato de piezas ya entregadas"), + ("stock_reciente", "Stock de Envato reciente", "tomas de Envato de piezas en curso"), + ("proxies", "Proxies", "copias ligeras que Resolve/Premiere regeneran solas"), + ("basura", "Basura de sistema y autoguardados", ".DS_Store, Thumbs.db, cachés, autosaves de After Effects"), + ("exportados", "Exportados / masters", "videos finales renderizados"), + ("video_otro", "Otros videos", "videos que no son de cámara ni exportados (descargas, referencias, IA)"), + ("proyectos", "Proyectos de edición y diseño", ".drp, .prproj, .aep, .psd, .ai…"), + ("imagenes", "Imágenes, diseño e IA", "PNG/JPG sueltos, capturas, imágenes generadas"), + ("documentos", "Documentos", "guiones, PDF, hojas de cálculo"), + ("otros", "Otros archivos", "todo lo demás"), +] +TITULO = {k: t for k, t, _ in GRUPOS} +QUE_ES = {k: q for k, _, q in GRUPOS} + + +def _grupo(f, limite_stock): + if f["es_basura"] or f["rol"] == "basura": + return "basura" + if f["es_proxy"] or f["rol"] == "proxy": + return "proxies" + if f["es_stock"] or f["rol"] == "stock": + return "stock_viejo" if f["mtime"] < limite_stock else "stock_reciente" + rol = f["rol"] + if rol == "camara_raw": + return "camara_raw" + if rol in ("camara_video", "camara_sidecar"): + return "camara_video" + if rol in ("audio", "audio_grabado"): + return "audio" + if rol in ("celular_video", "celular_foto"): + return "celular" + if rol == "foto_camara": + return "fotos" + if rol == "video": + return "exportados" if (f["contexto"] or "") == "EXPORTADOS" else "video_otro" + if rol in ("proyecto", "diseno", "fuente"): + return "proyectos" + if rol in ("imagen", "ia", "captura"): + return "imagenes" + if rol == "documento": + return "documentos" + return "otros" + + +def diagnostico(raiz): + """Resumen corto y sencillo de lo que hay en la carpeta, por tipo de archivo.""" + raiz = os.path.normpath(raiz) + con = O.db() + filas = con.execute("SELECT rol, bytes, mtime, es_basura, es_proxy, es_stock, contexto, fecha FROM archivos WHERE raiz = ?", + (raiz,)).fetchall() + esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() + con.close() + meses = int(O.CONFIG["optimizar"].get("meses_minimos", 3)) + limite = time.time() - meses * 30.4 * 86400 + acc = {k: {"clave": k, "titulo": TITULO[k], "que_es": QUE_ES[k], "n": 0, "bytes": 0, "desde": None, "hasta": None} for k, _, _ in GRUPOS} + total_b = 0 + for f in filas: + g = acc[_grupo(f, limite)] + g["n"] += 1 + g["bytes"] += f["bytes"] or 0 + total_b += f["bytes"] or 0 + fe = (f["fecha"] or "")[:7] + if fe: + g["desde"] = fe if not g["desde"] or fe < g["desde"] else g["desde"] + g["hasta"] = fe if not g["hasta"] or fe > g["hasta"] else g["hasta"] + grupos = [g for g in acc.values() if g["n"]] + grupos.sort(key=lambda g: -g["bytes"]) + for g in grupos: + g["pct"] = round(g["bytes"] * 100 / total_b, 1) if total_b else 0 + # frases de diagnóstico (lo importante, en una línea cada una) + frases = [] + b = lambda k: acc[k]["bytes"] # noqa + if b("camara_raw") + b("camara_video"): + frases.append(f"El material de cámara ocupa {O._gb(b('camara_raw') + b('camara_video'))}" + f" ({round((b('camara_raw') + b('camara_video')) * 100 / total_b) if total_b else 0} % del total).") + if b("stock_viejo"): + frases.append(f"Hay {O._gb(b('stock_viejo'))} de stock de Envato en proyectos de hace más de {meses} meses: se puede borrar sin perder nada.") + if b("proxies"): + frases.append(f"Los proxies suman {O._gb(b('proxies'))}: se regeneran solos, no hace falta guardarlos.") + if acc["basura"]["n"]: + frases.append(f"{acc['basura']['n']:,} archivos de basura de sistema o autoguardado ({O._gb(b('basura'))}).") + dup = O.duplicados(raiz) + if dup["grupos"]: + frases.append(f"Hay {dup['n']} archivos repetidos ({O._gb(dup['bytes'])}) en {len(dup['grupos'])} grupos: se pueden juntar en subcarpetas Duplicados para revisarlos a mano.") + if not frases: + frases.append("Nada llamativo: la carpeta no tiene grandes bolsas de peso que recortar.") + return {"raiz": raiz, "n": len(filas), "bytes": total_b, "grupos": grupos, "frases": frases, "meses_stock": meses, + "duplicados": {"grupos": len(dup["grupos"]), "n": dup["n"], "bytes": dup["bytes"], "identicos": dup["identicos"], "versiones": dup["versiones"]}, + "escaneo": dict(esc) if esc else None} + + +# ---------------------------------------------------------------- pasos del asistente +def pasos(diag, plan_filas): + """Los pasos que se le preguntan al usuario, solo los que aplican a esta carpeta.""" + g = {x["clave"]: x for x in diag["grupos"]} + por_cubeta = {} + for f in plan_filas: + c = por_cubeta.setdefault(f["cubeta"], {"n": 0, "bytes": 0, "filas": 0}) + c["filas"] += 1 + c["n"] += f["n"] + c["bytes"] += f["bytes"] + out = [] + + def paso(clave, titulo, resumen, opciones, defecto): + out.append({"clave": clave, "titulo": titulo, "resumen": resumen, "opciones": opciones, "defecto": defecto}) + + if "basura" in g or "proxies" in g: + partes = [f"{g[k]['n']:,} {TITULO[k].split(' ')[0].lower()} ({O._gb(g[k]['bytes'])})" for k in ("basura", "proxies") if k in g] + paso("limpieza", "Basura y proxies", " y ".join(partes), + [("papelera", "Apartarlos a _Para borrar (recomendado; nada se borra, se pueden recuperar)"), ("dejar", "Dejarlos donde están")], "papelera") + cam = [k for k in ("camara_raw", "camara_video", "audio", "celular", "fotos") if k in g] + if cam: + resumen = "; ".join(f"{TITULO[k]}: {g[k]['n']:,} archivos, {O._gb(g[k]['bytes'])}" for k in cam) + paso("material", "Material de cámara, audio, celular y fotos", resumen, + [("ordenar", "Ordenarlo por mes y por fuente en 00_Grabacion (recomendado)"), ("dejar", "No tocarlo")], "ordenar") + if "camara_raw" in g or "camara_video" in g: + h = O.herramientas() + aviso = "" + if "camara_raw" in g and not (h.get("resolve") or {}).get("ok"): + aviso = " Los BRAW necesitan DaVinci Resolve Studio abierto en un proyecto." + paso("convertir", "Clips de cámara de proyectos ya entregados", + f"Convertir a MP4 H.265 de 10 bits los clips con más de {diag['meses_stock']} meses: pesan ~10 veces menos y conservan margen de color.{aviso}", + [("no", "Por ahora no (se puede hacer luego desde la pestaña Optimizar)"), + ("si", "Sí, convertir y reemplazar los originales (los originales quedan en _Para borrar/originales)")], "no") + dup = diag.get("duplicados") or {} + if dup.get("grupos"): + det = [] + if dup.get("identicos"): + det.append(f"{dup['identicos']} idénticos (mismo nombre y peso)") + if dup.get("versiones"): + det.append(f"{dup['versiones']} en dos formatos (MP4 y MOV del mismo clip)") + paso("duplicados", "Material repetido", + f"{dup['n']} copias en {dup['grupos']} grupos ({O._gb(dup['bytes'])}): {' y '.join(det)}. No se borra nada: cada copia se junta en una subcarpeta Duplicados al lado del que se queda, para revisarla a mano.", + [("apartar", "Juntarlas en Duplicados junto al original (recomendado)"), ("dejar", "Dejarlas donde están")], "apartar") + if "stock_viejo" in g: + paso("stock", "Stock de Envato viejo", + f"{g['stock_viejo']['n']:,} tomas de Envato ({O._gb(g['stock_viejo']['bytes'])}) en proyectos de hace más de {diag['meses_stock']} meses", + [("apartar", "Apartarlas a _Para borrar/stock (recomendado; luego una persona las borra a mano)"), ("dejar", "Dejarlas")], "apartar") + carpetas = {c: v for c, v in por_cubeta.items() if c not in ("BASURA", "PROXY", "GRABACION", "CELULAR", "FOTOS")} + if carpetas: + resumen = ", ".join(f"{c.lower()} {v['filas']}" for c, v in sorted(carpetas.items(), key=lambda x: -x[1]["bytes"])) + paso("carpetas", "Carpetas: meses, piezas, marca, exportados, proyectos", + f"{sum(v['filas'] for v in carpetas.values())} movimientos propuestos ({resumen})", + [("seguras", "Aplicar las de confianza alta y media (recomendado)"), ("alta", "Solo las de confianza alta"), + ("todas", "Todas, incluidas las dudosas y los renombrados cosméticos"), ("dejar", "Ninguna")], "seguras") + paso("respaldo", "Respaldo compacto en otro disco", + "Copia organizada por carpeta y tipo, con los clips ya convertidos en vez de los originales", + [("no", "Ahora no"), ("si", f"Sí, a {O.CONFIG.get('respaldo', {}).get('destino', 'T:\\Archivo Labstream')}")], "no") + return out + + +def filas_elegidas(plan_filas, dec): + """Traduce las decisiones a las filas del plan que se aplican.""" + out = [] + for f in plan_filas: + c = f["cubeta"] + if c in ("BASURA", "PROXY"): + if dec.get("limpieza", "papelera") == "papelera": + out.append(f) + elif c in ("GRABACION", "CELULAR", "FOTOS"): + if dec.get("material", "ordenar") == "ordenar": + out.append(f) + else: + m = dec.get("carpetas", "seguras") + if m == "todas" or (m == "seguras" and f["marcado"]) or (m == "alta" and f["confianza"] == "alta"): + out.append(f) + return out + + +# ---------------------------------------------------------------- ejecución con progreso +PLAN_CACHE = {} +ESTADO = {"activo": False, "raiz": "", "paso": "", "pct": 0, "texto": "", "hechas": 0, "total": 0, "resultado": None, "error": ""} + + +def _prog(paso, hechas, total, texto=""): + ESTADO.update(paso=paso, hechas=hechas, total=total, pct=int(hechas * 100 / total) if total else 0, texto=texto) + + +def ejecutar(raiz, dec, plan_filas=None, en_hilo=False): + """Aplica las decisiones. Actualiza ESTADO para las barras de progreso.""" + if en_hilo: + threading.Thread(target=ejecutar, args=(raiz, dec, plan_filas, False), daemon=True).start() + return + raiz = os.path.normpath(raiz) + r = O.raiz_cfg(raiz) + ESTADO.update(activo=True, raiz=raiz, resultado=None, error="") + res = {"lotes": [], "movimientos": 0, "errores": [], "stock": 0, "convertidos": 0, "respaldo": None} + try: + if plan_filas is None: + _prog("plan", 0, 0, "pensando el plan…") + plan_filas = O.Plan(r).correr()["filas"] + if dec.get("stock") == "apartar": + # primero el stock viejo, para que en _Para borrar/stock quede con su ruta original + _prog("stock", 0, 0, "apartando stock de Envato viejo") + s = O.stock_viejo(raiz, O.CONFIG["optimizar"]["meses_minimos"], ["Video", "Audio", "Imagenes"]) + rutas = [x for g in s["grupos"] for x in g["rutas"]] + if rutas: + st = O.stock_a_papelera(raiz, rutas) + res["lotes"].append(st.get("lote")) + res["stock"] = st.get("movimientos", 0) + plan_filas = [f for f in plan_filas if f["cubeta"] != "STOCK"] + elegidas = filas_elegidas(plan_filas, dec) + if elegidas: + _prog("ordenar", 0, len(elegidas), "moviendo carpetas y archivos") + hilo = threading.Thread(target=lambda: res.update(_ap=O.aplicar(raiz, elegidas)), daemon=True) + hilo.start() + while hilo.is_alive(): + a = O.APLICANDO + _prog("ordenar", a["hechas"], a["total"] or len(elegidas), a["actual"]) + time.sleep(0.3) + ap = res.pop("_ap", None) or {} + res["lotes"].append(ap.get("lote")) + res["movimientos"] += ap.get("movimientos", 0) + res["errores"] += ap.get("errores", []) + if dec.get("duplicados") == "apartar": + _prog("duplicados", 0, 0, "juntando los duplicados en subcarpetas Duplicados") + dd = O.apartar_duplicados(raiz) + res["lotes"].append(dd.get("lote")) + res["duplicados"] = dd.get("movimientos", 0) + res["movimientos"] += dd.get("movimientos", 0) + res["errores"] += dd.get("errores", []) + if dec.get("convertir") == "si": + o = O.CONFIG["optimizar"] + _prog("analizar", 0, 0, "leyendo los clips con ffprobe") + hilo = threading.Thread(target=O.analizar, args=(raiz, o["min_mb"]), daemon=True) + hilo.start() + while hilo.is_alive(): + a = O.ANALISIS.get(raiz) or {} + _prog("analizar", a.get("hechos", 0), a.get("total", 0), "leyendo los clips con ffprobe") + time.sleep(0.5) + cands = [c for c in O.candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"]) if not c["excluido"] and not c["en_cola"]] + ids = O.encolar([c["ruta"] for c in cands], o["perfil"], o["chroma"], o["color"], raiz) + if ids: + O.iniciar_cola() + total = len(ids) + while O.COLA["activa"]: + con = O.db() + hechos = con.execute("SELECT COUNT(*) FROM trabajos WHERE id IN (%s) AND estado NOT IN ('pendiente','corriendo')" % ",".join(map(str, ids))).fetchone()[0] + act = con.execute("SELECT ruta, pct FROM trabajos WHERE estado = 'corriendo' LIMIT 1").fetchone() + con.close() + _prog("convertir", hechos, total, f"{os.path.basename(act['ruta'])} {act['pct'] or 0}%" if act else O.COLA["mensaje"]) + time.sleep(1) + vf = O.verificar(ids) + ok = [v["id"] for v in vf if v.get("ok")] + if ok: + O.reemplazar(ok) + res["convertidos"] = len(ok) + res["conversion_fallida"] = len(ids) - len(ok) + if dec.get("respaldo") == "si": + destino = O.CONFIG.get("respaldo", {}).get("destino") or "T:\\Archivo Labstream" + plan = O.plan_respaldo(raiz, destino, True, False, True) + _prog("respaldo", 0, plan["n"], f"copiando a {plan['base']}") + hilo = threading.Thread(target=O.copiar_respaldo, args=(raiz, plan), daemon=True) + hilo.start() + while hilo.is_alive(): + rb = O.RESPALDO + _prog("respaldo", rb.get("hechos", 0), rb.get("total", plan["n"]), rb.get("actual", "")) + time.sleep(0.5) + res["respaldo"] = {"base": plan["base"], "copiados": O.RESPALDO.get("copiados", 0), "saltados": O.RESPALDO.get("saltados", 0)} + res["lotes"] = [x for x in res["lotes"] if x] + ESTADO["resultado"] = res + _prog("fin", 1, 1, "listo") + except Exception as ex: # noqa + ESTADO["error"] = str(ex) + ESTADO["resultado"] = res + finally: + ESTADO["activo"] = False + return res + + +# ---------------------------------------------------------------- consola +ANCHO = 34 + + +def barra(hechas, total, texto="", final=False): + if total: + pct = min(100, int(hechas * 100 / total)) + llenos = int(ANCHO * pct / 100) + b = "█" * llenos + "░" * (ANCHO - llenos) + linea = f"\r [{b}] {pct:3d}% {hechas:,}/{total:,} {texto[-40:]:<40}" + else: + giro = "|/-\\"[int(time.time() * 4) % 4] + linea = f"\r [{giro}] {hechas:,} {texto[-52:]:<52}" + sys.stdout.write(linea[:120]) + if final: + sys.stdout.write("\n") + sys.stdout.flush() + + +def escanear_con_barra(raiz): + raiz = os.path.normpath(raiz) + con = O.db() + prev = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() + con.close() + estimado = prev["n"] if prev else 0 + print(f"\nEscaneando {raiz}") + hilo = threading.Thread(target=O.escanear, args=(raiz,), daemon=True) + hilo.start() + while hilo.is_alive(): + p = O.PROGRESO.get(raiz) or {} + barra(p.get("n", 0), estimado if estimado and p.get("n", 0) <= estimado else 0, os.path.basename(p.get("actual") or "")) + time.sleep(0.25) + p = O.PROGRESO[raiz] + if p["estado"] != "listo": + print("\nERROR al escanear:", p.get("error")) + return False + barra(p["n"], p["n"], f"{O._gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s", final=True) + return True + + +def _preguntar(paso, auto): + print(f"\n{paso['titulo']}\n {paso['resumen']}") + for i, (clave, texto) in enumerate(paso["opciones"], 1): + marca = " (Enter)" if clave == paso["defecto"] else "" + print(f" {i}. {texto}{marca}") + if auto: + return paso["defecto"] + while True: + try: + t = input(" Elige: ").strip() + except EOFError: + return paso["defecto"] + if not t: + return paso["defecto"] + if t.isdigit() and 1 <= int(t) <= len(paso["opciones"]): + return paso["opciones"][int(t) - 1][0] + for clave, _ in paso["opciones"]: + if t.lower() == clave: + return clave + print(" Escribe el número de la opción.") + + +def cli(ruta, args): + r = O.cli_raiz(ruta, args) + if not r: + return 1 + raiz = r["ruta"] + if not escanear_con_barra(raiz): + return 1 + d = diagnostico(raiz) + print(f"\nDIAGNÓSTICO · {r['cliente']} · {d['n']:,} archivos · {O._gb(d['bytes'])}") + for g in d["grupos"]: + rango = f"{g['desde']} → {g['hasta']}" if g["desde"] and g["desde"] != g["hasta"] else (g["desde"] or "") + print(f" {O._gb(g['bytes']):>9} {g['pct']:5.1f} % {g['n']:>7,} {g['titulo']} {rango}") + for fr in d["frases"]: + print(" •", fr) + print("\nPensando el plan…", end="", flush=True) + plan_filas = O.Plan(r).correr()["filas"] + print(f" {len(plan_filas)} movimientos posibles.") + auto = "--si" in args + dec = {} + for paso in pasos(d, plan_filas): + dec[paso["clave"]] = _preguntar(paso, auto) + elegidas = filas_elegidas(plan_filas, dec) + print(f"\nRESUMEN: se moverán {len(elegidas)} carpetas/grupos ({sum(f['n'] for f in elegidas):,} archivos, {O._gb(sum(f['bytes'] for f in elegidas))})" + + (", se apartará el stock viejo" if dec.get("stock") == "apartar" else "") + + (", se convertirán los clips viejos a 10 bits" if dec.get("convertir") == "si" else "") + + (", y se hará el respaldo" if dec.get("respaldo") == "si" else "") + ".") + if "--simular" in args: + print("Simulación: no se movió nada.") + return 0 + if not O._confirmar("¿Empezamos? (nada se borra; lo sobrante va a _Para borrar y todo se puede deshacer)", auto): + print("Cancelado. No se movió nada.") + return 0 + hilo = threading.Thread(target=ejecutar, args=(raiz, dec, plan_filas), daemon=True) + hilo.start() + paso_ant = None + while hilo.is_alive(): + e = ESTADO + if e["paso"] != paso_ant: + if paso_ant: + barra(e["total"] or 1, e["total"] or 1, "", final=True) + paso_ant = e["paso"] + print({"plan": "Plan", "ordenar": "Ordenando", "stock": "Stock viejo", "analizar": "Analizando clips", "convertir": "Convirtiendo a 10 bits", + "respaldo": "Respaldo", "fin": "Fin"}.get(e["paso"], e["paso"])) + barra(e["hechas"], e["total"], e["texto"]) + time.sleep(0.3) + barra(1, 1, "", final=True) + res = ESTADO["resultado"] or {} + if ESTADO["error"]: + print("ERROR:", ESTADO["error"]) + print(f"\nHecho: {res.get('movimientos', 0)} movimientos" + + (f", {res['stock']} tomas de stock apartadas" if res.get("stock") else "") + + (f", {res['convertidos']} clips convertidos" if res.get("convertidos") else "") + + (f", respaldo en {res['respaldo']['base']} ({res['respaldo']['copiados']} copiados)" if res.get("respaldo") else "") + ".") + for e in res.get("errores", [])[:20]: + print(" ERROR:", e) + for lote in res.get("lotes", []): + print(f"Para deshacer: OrganizadorLabstream.exe deshacer {lote}") + return 0 if not res.get("errores") else 2 diff --git a/empaquetar.py b/empaquetar.py new file mode 100644 index 0000000..1f46ee8 --- /dev/null +++ b/empaquetar.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +r""" +Construye la versión portátil y los paquetes de actualización. +Uso: py -3.12 empaquetar.py [ruta\a\ffmpeg\bin] +Resultado: + dist\OrganizadorLabstream\ carpeta completa (con ffmpeg) + OrganizadorLabstream-portatil-.zip primera instalación (con ffmpeg, ~190 MB) + OrganizadorLabstream-app-.zip actualización (sin ffmpeg ni datos, ~15 MB) + ..\labstream-os\public\herramientas\organizador.json manifiesto para el OS (versión, tamaños, sha256) +Después: subir los dos zips al NAS (storage/herramientas/) y desplegar el OS. +""" +import os +import re +import sys +import glob +import json +import shutil +import hashlib +import zipfile +import datetime +import subprocess + +AQUI = os.path.dirname(os.path.abspath(__file__)) +os.chdir(AQUI) +VERSION = re.search(r'^VERSION = "([^"]+)"', open("organizador.py", encoding="utf-8").read(), re.M).group(1) +print("versión", VERSION) + +ffbin = sys.argv[1] if len(sys.argv) > 1 else "" +if not ffbin: + for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")): + ffbin = c + if not ffbin and shutil.which("ffmpeg"): + ffbin = os.path.dirname(shutil.which("ffmpeg")) +print("ffmpeg desde:", ffbin or "(no encontrado: el zip irá sin ffmpeg)") + +for d in ("build", "dist"): + shutil.rmtree(d, ignore_errors=True) +subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed", + "--name", "OrganizadorLabstream", "--icon", os.path.join("static", "organilab.ico"), + "--hidden-import", "resolve_bridge", "--hidden-import", "reglas", + "--hidden-import", "asistente", "--hidden-import", "actualizador", + "--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet", + "--exclude-module", "tkinter", "organizador.py"]) +dest = os.path.join("dist", "OrganizadorLabstream") +shutil.copytree("static", os.path.join(dest, "static")) +os.makedirs(os.path.join(dest, "datos"), exist_ok=True) +for f in ("config.json", "diccionario.json", "plantillas.json"): + src = os.path.join("datos", f) + if os.path.exists(src): + shutil.copy(src, os.path.join(dest, "datos", f)) +# la config del paquete sale limpia: sin el recorrido ya visto ni raíces de prueba +cfg_p = os.path.join(dest, "datos", "config.json") +if os.path.exists(cfg_p): + cfg = json.load(open(cfg_p, encoding="utf-8")) + cfg.pop("tour_visto", None) + cfg["raices"] = [] # el paquete sale SIN carpetas: cada equipo añade las suyas y se escanean al añadirlas + json.dump(cfg, open(cfg_p, "w", encoding="utf-8"), ensure_ascii=False, indent=2) +shutil.copy("README.md", dest) +BAT = ('@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\nif "%~1"=="" (\r\n echo Arrastra una carpeta de cliente encima de este archivo.\r\n pause\r\n exit /b\r\n)\r\n' + '"%~dp0OrganizadorLabstream.exe" {cmd} "%~1"\r\n') +with open(os.path.join(dest, "Organizar carpeta (arrastra aqui).bat"), "w", encoding="cp1252") as fh: + fh.write(BAT.format(cmd="asistente")) +with open(os.path.join(dest, "Apartar stock Envato viejo (arrastra aqui).bat"), "w", encoding="cp1252") as fh: + fh.write(BAT.format(cmd="stock")) +with open(os.path.join(dest, "LEEME.txt"), "w", encoding="utf-8") as fh: + fh.write(f"OrganiLAB · Organizador Labstream {VERSION} (portátil)\n\n" + "ORDENAR UNA CARPETA: arrastra la carpeta del cliente encima de «Organizar carpeta (arrastra aqui).bat»\n" + "(o del propio .exe). Se escanea con barra de progreso, sale el diagnóstico y el asistente pregunta paso a paso.\n" + "Por consola: OrganizadorLabstream.exe asistente \"D:\\Clientes\\Carpeta\" [--simular] [--si]\n" + "Más comandos: OrganizadorLabstream.exe ayuda\n\n" + "1. Doble clic en OrganizadorLabstream.exe abre OrganiLAB en su propia ventana (sin navegador).\n" + "2. No hace falta instalar nada: Python y ffmpeg van dentro.\n3. Para convertir BRAW hace falta DaVinci Resolve Studio abierto en un proyecto.\n" + "4. La app NUNCA borra: lo que sobra va a _Para borrar y lo repetido a subcarpetas Duplicados, para que una persona lo revise.\n" + "5. Todo lo que aprende y la configuración queda en la carpeta datos\\ (llévala contigo si cambias de PC).\n" + "6. Se actualiza solo desde Labstream OS (os.labstreamsas.com): avisa cuando hay versión nueva.\n" + "Si Windows SmartScreen avisa, «Más información» → «Ejecutar de todas formas».\n") + +# ── paquete APP (actualización): todo menos ffmpeg y datos ── +app_zip = f"OrganizadorLabstream-app-{VERSION}.zip" +with zipfile.ZipFile(app_zip, "w", zipfile.ZIP_DEFLATED) as z: + for raiz, dirs, files in os.walk(dest): + rel_raiz = os.path.relpath(raiz, dest) + primero = rel_raiz.split(os.sep)[0] + if primero in ("ffmpeg", "datos"): + continue + for f in files: + p = os.path.join(raiz, f) + z.write(p, "OrganizadorLabstream/" + os.path.relpath(p, dest).replace(os.sep, "/")) + +# ── paquete COMPLETO (primera instalación): con ffmpeg ── +if ffbin: + os.makedirs(os.path.join(dest, "ffmpeg"), exist_ok=True) + for exe in ("ffmpeg.exe", "ffprobe.exe"): + shutil.copy(os.path.join(ffbin, exe), os.path.join(dest, "ffmpeg", exe)) + lic = os.path.join(os.path.dirname(ffbin), "LICENSE") + if os.path.exists(lic): + shutil.copy(lic, os.path.join(dest, "ffmpeg", "LICENSE.txt")) +for viejo in glob.glob("OrganizadorLabstream-portatil*.zip") + [z for z in glob.glob("OrganizadorLabstream-app-*.zip") if z != app_zip]: + os.remove(viejo) +full_zip = f"OrganizadorLabstream-portatil-{VERSION}.zip" +shutil.make_archive(full_zip[:-4], "zip", "dist", "OrganizadorLabstream") + + +def sha(p): + h = hashlib.sha256() + with open(p, "rb") as fh: + for b in iter(lambda: fh.read(1 << 20), b""): + h.update(b) + return h.hexdigest() + + +notas = os.environ.get("NOTAS", "") +manifiesto = { + "nombre": "OrganiLAB", + "version": VERSION, + "builtAt": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"), + "archivo": full_zip, "size": os.path.getsize(full_zip), "sha256": sha(full_zip), + "app": {"archivo": app_zip, "size": os.path.getsize(app_zip), "sha256": sha(app_zip), "url": f"/api/herramientas/{app_zip}"}, + "notes": notas, +} +ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json") +if os.path.isdir(os.path.dirname(ruta_os)): + previo = {} + try: + previo = json.load(open(ruta_os, encoding="utf-8")) + except Exception: # noqa + pass + if not notas: + manifiesto["notes"] = previo.get("notes", "") if previo.get("version") == VERSION else "" + json.dump(manifiesto, open(ruta_os, "w", encoding="utf-8"), ensure_ascii=False, indent=2) + print("manifiesto escrito en", ruta_os) +json.dump(manifiesto, open("manifiesto.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2) +print(f"listo: {full_zip} ({os.path.getsize(full_zip) / 1e6:.0f} MB) · {app_zip} ({os.path.getsize(app_zip) / 1e6:.0f} MB)") +print("Sube los dos zips a storage/herramientas/ del NAS y despliega el OS.") diff --git a/hacer_icono.py b/hacer_icono.py new file mode 100644 index 0000000..3733c2b --- /dev/null +++ b/hacer_icono.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""Genera static/organilab.png (256 px) y static/organilab.ico (256/48/32/16) sin dependencias: +el logo OrganiLAB, cuadrado azul redondeado con tres barras blancas.""" +import os +import zlib +import struct + +AQUI = os.path.dirname(os.path.abspath(__file__)) +AZUL = (0x1F, 0x57, 0xD6) + + +def pinta(n): + """devuelve filas RGBA del logo a n×n""" + r = n * 7 / 32 # radio de las esquinas (7/32 como el SVG) + barras = [(8, 18, 1.0), (14, 13, .85), (20, 8, .65)] # (y, ancho, opacidad) en unidades de 32 + filas = [] + for y in range(n): + fila = bytearray() + for x in range(n): + # cuadrado redondeado con antialias sencillo (4 muestras) + dentro = 0 + for sx, sy in ((.25, .25), (.75, .25), (.25, .75), (.75, .75)): + px, py = x + sx, y + sy + cx = min(max(px, r), n - r) + cy = min(max(py, r), n - r) + if (px - cx) ** 2 + (py - cy) ** 2 <= r * r: + dentro += 1 + a = dentro / 4 + if a == 0: + fila += bytes((0, 0, 0, 0)) + continue + col = list(AZUL) + for by, bw, op in barras: + y0, y1 = by * n / 32, (by + 4) * n / 32 + x0, x1 = 7 * n / 32, (7 + bw) * n / 32 + rb = 2 * n / 32 + if y0 <= y + .5 < y1 and x0 <= x + .5 < x1: + # esquinas de la barra redondeadas + cx = min(max(x + .5, x0 + rb), x1 - rb) + cy = min(max(y + .5, y0 + rb), y1 - rb) + if (x + .5 - cx) ** 2 + (y + .5 - cy) ** 2 <= rb * rb: + col = [int(c + (255 - c) * op) for c in AZUL] + fila += bytes((col[0], col[1], col[2], int(255 * a))) + filas.append(bytes(fila)) + return filas + + +def png(n): + filas = pinta(n) + raw = b"".join(b"\x00" + f for f in filas) + + def chunk(t, d): + return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + + +tamanos = (256, 48, 32, 16) +imgs = [png(t) for t in tamanos] +with open(os.path.join(AQUI, "static", "organilab.png"), "wb") as fh: + fh.write(imgs[0]) +# ICO con PNG incrustados (válido desde Windows Vista) +cab = struct.pack(" + "sufijo": "_10bit", + "ffmpeg": "ffmpeg", + "ffprobe": "ffprobe", + "python_resolve": "", # vacío = el mismo intérprete + }, +} + +PERFILES = { + "ahorro": {"bpp": 0.055, "cq": 27, "nombre": "Ahorro (pesa menos)"}, + "equilibrado": {"bpp": 0.085, "cq": 24, "nombre": "Equilibrado (recomendado)"}, + "maxima": {"bpp": 0.14, "cq": 21, "nombre": "Máxima calidad"}, +} + + +def cargar_json(ruta, defecto): + try: + with open(ruta, "r", encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return json.loads(json.dumps(defecto)) + + +def guardar_json(ruta, datos): + tmp = ruta + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(datos, f, ensure_ascii=False, indent=2) + os.replace(tmp, ruta) + + +CONFIG = cargar_json(CONFIG_PATH, CONFIG_DEFECTO) +for k, v in CONFIG_DEFECTO.items(): # completar claves nuevas sin pisar lo del usuario + CONFIG.setdefault(k, v) + if isinstance(v, dict): + for kk, vv in v.items(): + CONFIG[k].setdefault(kk, vv) +guardar_json(CONFIG_PATH, CONFIG) + + +def ruta_herramienta(h): + """ffmpeg/ffprobe: primero el empaquetado en ffmpeg\\ junto al programa, luego lo que diga la config, luego el PATH. + No se guarda en la config para que la carpeta se pueda mover de sitio.""" + local = os.path.join(BASE, "ffmpeg", h + ".exe") + if os.path.exists(local): + return local + return CONFIG["optimizar"].get(h) or h + + +def herramientas(): + """qué hay disponible: ffmpeg/ffprobe y Resolve""" + out = {} + for h in ("ffmpeg", "ffprobe"): + ruta = ruta_herramienta(h) + ok = os.path.exists(ruta) or shutil.which(ruta) is not None + out[h] = {"ruta": ruta, "ok": ok, "empaquetado": ruta.lower().startswith(BASE.lower())} + out["resolve"] = {"ok": os.path.exists(r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll"), "ruta": r"C:\Program Files\Blackmagic Design\DaVinci Resolve"} + out["congelado"] = CONGELADO + return out + +DICC = cargar_json(DICC_PATH, {"carpetas": R.DICCIONARIO_SEMILLA, "reglas": [], "aprendido": []}) +DICC.setdefault("carpetas", dict(R.DICCIONARIO_SEMILLA)) +DICC.setdefault("reglas", []) +DICC.setdefault("aprendido", []) +for k, v in R.DICCIONARIO_SEMILLA.items(): # palabras nuevas de la semilla, sin pisar lo aprendido + DICC["carpetas"].setdefault(k, v) +guardar_json(DICC_PATH, DICC) + +PLANTILLAS = cargar_json(PLANTILLAS_PATH, R.PLANTILLAS) +for t, d in R.PLANTILLAS.items(): + PLANTILLAS.setdefault(t, d) + for b, p in d.items(): + PLANTILLAS[t].setdefault(b, p) +guardar_json(PLANTILLAS_PATH, PLANTILLAS) + + +def raiz_cfg(ruta): + ruta = os.path.normpath(ruta) + for r in CONFIG["raices"]: + if os.path.normpath(r["ruta"]).lower() == ruta.lower(): + return r + return None + + +def dentro_de(raiz, ruta): + """True si `ruta` está dentro de `raiz` (o es la propia raíz). Evita mover cosas fuera de la carpeta del cliente.""" + try: + a = os.path.normcase(os.path.normpath(raiz)) + b = os.path.normcase(os.path.normpath(ruta)) + except (TypeError, ValueError): + return False + return b == a or b.startswith(a.rstrip(os.sep) + os.sep) + + +# ========================================================================== +# Base de datos +# ========================================================================== + +def db(): + con = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=60) + con.row_factory = sqlite3.Row + return con + + +def init_db(): + con = db() + con.executescript(""" + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS archivos ( + ruta TEXT PRIMARY KEY, raiz TEXT, dir TEXT, nombre TEXT, ext TEXT, bytes INTEGER, mtime REAL, + rol TEXT, fuente TEXT, contexto TEXT, anio_ruta INTEGER, mes_ruta INTEGER, + fecha TEXT, fecha_origen TEXT, es_basura INTEGER, es_proxy INTEGER, + codec TEXT, perfil TEXT, pix_fmt TEXT, ancho INTEGER, alto INTEGER, fps REAL, dur REAL, + kbps INTEGER, alfa INTEGER, acodec TEXT, analizado INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS ix_arch_dir ON archivos(dir); + CREATE INDEX IF NOT EXISTS ix_arch_raiz ON archivos(raiz); + CREATE INDEX IF NOT EXISTS ix_arch_nb ON archivos(nombre, bytes); + CREATE TABLE IF NOT EXISTS carpetas ( + ruta TEXT PRIMARY KEY, raiz TEXT, nombre TEXT, n INTEGER, bytes INTEGER, + anio_moda INTEGER, mes_moda INTEGER, frac_moda REAL, dias_rango INTEGER, + es_proxy INTEGER, es_basura INTEGER + ); + CREATE INDEX IF NOT EXISTS ix_carp_raiz ON carpetas(raiz); + CREATE TABLE IF NOT EXISTS escaneos (raiz TEXT PRIMARY KEY, ts REAL, n INTEGER, bytes INTEGER, seg REAL); + CREATE TABLE IF NOT EXISTS trabajos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ruta TEXT, salida TEXT, motor TEXT, perfil TEXT, + chroma TEXT, color TEXT, kbps INTEGER, estado TEXT, pct REAL DEFAULT 0, log TEXT DEFAULT '', + bytes_in INTEGER, bytes_out INTEGER, creado REAL, fin REAL, raiz TEXT + ); + """) + con.commit() + for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",): + try: + con.execute(alter) + con.commit() + except sqlite3.OperationalError: + pass + con.close() + + +init_db() + + +def con_sep(ruta): + r"""la ruta con un separador al final, sin duplicarlo (N: ya lo trae)""" + ruta = os.path.normpath(ruta) + return ruta if ruta.endswith(os.sep) else ruta + os.sep + + +def esc_like(s): + return s.replace("!", "!!").replace("%", "!%").replace("_", "!_") + + +def archivos_bajo(con, carpeta): + """filas de archivos dentro de una carpeta (recursivo)""" + c = os.path.normpath(carpeta) + return con.execute( + "SELECT * FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'", + (c, esc_like(con_sep(c)) + "%")).fetchall() + + +def archivos_en(con, carpeta): + return con.execute("SELECT * FROM archivos WHERE dir = ?", (os.path.normpath(carpeta),)).fetchall() + + +# ========================================================================== +# Escaneo (inventario) +# ========================================================================== + +PROGRESO = {} # raiz -> dict + + +def contexto_dir(nombre, ctx_padre, nivel_padre): + """cubeta de contexto que hereda un archivo por la carpeta donde está""" + if R.detectar_anio(nombre): + return "ANIO", "anio" + if nivel_padre == "anio" and R.detectar_mes(nombre)[0]: + return "MES", "mes" + if nivel_padre == "mes" and R.detectar_pieza(nombre): + return "PIEZA", "pieza" + cub, _ = R.cubeta_carpeta(nombre, DICC["carpetas"]) + if cub in (None, "CONTENEDOR"): + return ctx_padre, nivel_padre if cub == "CONTENEDOR" else nivel_padre + if cub == "PIEZA": + return "PIEZA", "pieza" + return cub, nivel_padre + + +def escanear(ruta): + raiz = os.path.normpath(ruta) + prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""} + filas = [] + dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre] + props = ("n", "bytes") + + def agrega_dir(d, size, fecha_am): + info = dirs.get(d) + if info is None: + info = dirs[d] = [0, 0, Counter(), 0, 0, os.path.basename(d)] + info[0] += 1 + info[1] += size + info[2][fecha_am] += 1 + + def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes): + try: + it = os.scandir(path) + except OSError: + return + prog["actual"] = path + with it: + for e in it: + try: + nombre = e.name + if e.is_dir(follow_symlinks=False): + if nombre.lower() in ("#recycle", "@eadir"): + continue + p = en_proxy or R.es_proxy_dir(nombre) + b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM + s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip())) or R.norm(nombre) in ("elements", "elemnts") + c2, n2 = contexto_dir(nombre, ctx, nivel) + a2, m2 = anio, mes + if c2 == "ANIO": + a2, m2 = R.detectar_anio(nombre), None + elif c2 == "MES": + m2 = R.detectar_mes(nombre)[0] + dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre]) + dirs[e.path][3] = 1 if p else 0 + dirs[e.path][4] = 1 if b else 0 + walk(e.path, p, b, s, c2, n2, a2, m2) + else: + st = e.stat(follow_symlinks=False) + ext = os.path.splitext(nombre)[1].lower() + firma = R.firma_archivo(nombre) + rol = R.rol_archivo(nombre, ext, firma) + basura = 1 if (en_basura or rol == "basura") else 0 + proxy = 1 if (en_proxy or rol == "proxy") else 0 + stock = 1 if (en_stock or rol == "stock") else 0 + fecha, origen = R.fecha_de_nombre(nombre, st.st_mtime) + fy, fm = int(fecha[:4]), int(fecha[5:7]) + filas.append((e.path, raiz, path, nombre, ext, st.st_size, st.st_mtime, rol, firma or "", + ctx or "", anio, mes, fecha, origen, basura, proxy, stock)) + prog["n"] += 1 + prog["bytes"] += st.st_size + d = path + while True: + agrega_dir(d, st.st_size, (fy, fm)) + if os.path.normpath(d).lower() == raiz.lower(): + break + padre = os.path.dirname(d) + if padre == d: + break + d = padre + except OSError: + continue + + try: + walk(raiz, False, False, False, "", "raiz", None, None) + con = db() + with con: + # conservar el análisis de ffprobe de los archivos que no cambiaron + previo = {r["ruta"]: r for r in con.execute( + "SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, analizado " + "FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()} + con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,)) + con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,)) + con.executemany( + "INSERT OR REPLACE INTO archivos (ruta, raiz, dir, nombre, ext, bytes, mtime, rol, fuente, contexto, " + "anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + filas) + for f in filas: + pv = previo.get(f[0]) + 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=?, 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"], f[0])) + cfilas = [] + for d, info in dirs.items(): + n, b, fechas, p, bs, nombre = info + if fechas: + (ay, am), cnt = fechas.most_common(1)[0] + frac = cnt / n + claves = sorted(fechas) + d0 = datetime.date(claves[0][0], claves[0][1], 1) + d1 = datetime.date(claves[-1][0], claves[-1][1], 1) + rango = (d1 - d0).days + else: + ay, am, frac, rango = None, None, 0, 0 + cfilas.append((d, raiz, nombre, n, b, ay, am, frac, rango, p, bs)) + con.executemany("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", cfilas) + con.execute("INSERT OR REPLACE INTO escaneos VALUES (?,?,?,?,?)", + (raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"])) + con.close() + prog["estado"] = "listo" + PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco + except Exception as ex: # noqa + prog["estado"] = "error" + prog["error"] = repr(ex) + prog["fin"] = time.time() + + +def lanzar_escaneo(ruta): + raiz = os.path.normpath(ruta) + p = PROGRESO.get(raiz) + if p and p["estado"] == "corriendo": + return False + threading.Thread(target=escanear, args=(raiz,), daemon=True).start() + return True + + +# ========================================================================== +# Utilidades de fechas / nombres para el plan +# ========================================================================== + +def fecha_carpeta(con, ruta): + """(anio, mes, confianza, motivo) para una carpeta según sus archivos""" + fila = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (os.path.normpath(ruta),)).fetchone() + if not fila or not fila["anio_moda"]: + return None, None, "baja", "sin archivos con fecha" + frac = fila["frac_moda"] or 0 + if frac >= 0.8: + return fila["anio_moda"], fila["mes_moda"], "media", f"fecha de sus archivos ({int(frac * 100)}% del mismo mes)" + return fila["anio_moda"], fila["mes_moda"], "baja", f"fechas mezcladas ({fila['dias_rango']} días de rango)" + + +def limpiar_nombre(nombre): + n = re.sub(r"\s{2,}", " ", nombre).strip(" -_.") + return n or nombre + + +def destino_plantilla(tipo, cubeta, **kw): + plantilla = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get(cubeta) + if not plantilla: + plantilla = PLANTILLAS["contenido"].get(cubeta, "_Revisar/{nombre}") + anio = kw.get("anio") + mes = kw.get("mes") + partes = { + "anio": str(anio) if anio else "", + "mes": R.nombre_mes(mes) if mes else "", + "nombre": kw.get("nombre", ""), + "sub": kw.get("sub", ""), + "rel": kw.get("rel", ""), + "fuente": kw.get("fuente", ""), + } + # un valor vacío se lleva consigo el separador que lo precedía: "{anio}-{mes}_Grabacion" sin mes -> "2026_Grabacion" + vacio = "\x00" + partes_o = {k: (v if v != "" else vacio) for k, v in partes.items()} + segmentos = [] + for seg in plantilla.split("/"): + s = seg.format(**partes_o) + s = re.sub(r"[-_ ]*\x00", "", s) + if seg.startswith("{"): + s = s.lstrip("-_ ") + if seg.endswith("}"): + s = s.rstrip("-_ ") + if s: + segmentos.append(s) + if not segmentos: + segmentos = ["_Sin fecha"] + return os.sep.join(segmentos) + + +# ========================================================================== +# Planificador +# ========================================================================== + +class Plan: + def __init__(self, rcfg): + self.raiz = os.path.normpath(rcfg["ruta"]) + self.cliente = rcfg["cliente"] + self.tipo = rcfg.get("tipo", "contenido") + self.dicc = DICC["carpetas"] + self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)] + self.filas = [] + self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos + self.con = db() + self.avisos = [] + + # ---- helpers ---- + def rel(self, ruta): + return os.path.relpath(ruta, self.raiz) + + def abs_(self, rel): + return os.path.normpath(os.path.join(self.raiz, rel)) + + def regla_para(self, ruta): + rel = R.norm(self.rel(ruta)) + for r in self.reglas: + if R.norm(r.get("origen", "")) == rel: + return r + return None + + def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None, marcado=None): + destino = os.path.normpath(destino) + if archivos is None and os.path.normpath(origen).lower() == destino.lower(): + return + if n is None or bytes_ is None: + if archivos is not None: + n = len(archivos) + bytes_ = sum(a[1] for a in archivos) + else: + fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone() + n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0) + aviso_colision = "" + if archivos is None and accion != "renombrar" and os.path.isfile(destino): + aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá" + confianza, marcado = "baja", False + elif archivos is None and os.path.isdir(destino) and os.path.isdir(origen): + aviso_colision = " · la carpeta destino ya existe: se fusionan" + self.filas.append({ + "id": len(self.filas) + 1, + "origen": origen, + "origen_rel": self.rel(origen) if archivos is None else self.rel(origen), + "destino": destino, + "destino_rel": self.rel(destino), + "cubeta": cubeta, + "accion": accion, + "motivo": motivo + aviso_colision, + "confianza": confianza, + "n": n, + "bytes": bytes_, + "archivos": [a[0] for a in archivos] if archivos else None, + "marcado": (confianza in ("alta", "media")) if marcado is None else marcado, + "cosmetico": marcado is False and accion == "renombrar", + }) + + def suelto(self, fila, cubeta, destino_rel, motivo, confianza): + clave = (cubeta, destino_rel, motivo, confianza) + self.sueltos[clave].append((fila["ruta"], fila["bytes"])) + + def volcar_sueltos(self): + for (cubeta, destino_rel, motivo, confianza), archivos in self.sueltos.items(): + origen = os.path.dirname(archivos[0][0]) + self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos) + self.sueltos.clear() + + # ---- niveles ---- + def correr(self): + self.plan_dir(self.raiz, "raiz", None, None) + self.volcar_sueltos() + self.plan_basura() + self.con.close() + # primero las filas de archivos sueltos (rutas exactas), luego carpetas de más profunda a menos, + # para que un renombrado de mes no deje rutas viejas en filas que aún no se aplicaron + self.filas.sort(key=lambda f: (0 if f["archivos"] else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower())) + for i, f in enumerate(self.filas, 1): + f["id"] = i + return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos} + + def entradas(self, ruta): + try: + with os.scandir(ruta) as it: + ents = [e for e in it if e.name.lower() not in ("#recycle", "@eadir")] + except OSError as ex: + self.avisos.append(f"No se pudo leer {ruta}: {ex}") + return [], [] + dirs = sorted([e for e in ents if e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower()) + files = sorted([e for e in ents if not e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower()) + return dirs, files + + def plan_dir(self, ruta, nivel, anio, mes): + dirs, files = self.entradas(ruta) + # numeración de piezas dentro de un mes + max_num = 0 + if nivel == "mes": + for e in dirs: + p = R.detectar_pieza(e.name) + if p: + max_num = max(max_num, p[0]) + for e in dirs: + self.plan_subdir(e, nivel, anio, mes, ruta, max_num) + if nivel == "mes" and not R.detectar_pieza(e.name): + cub, _ = R.cubeta_carpeta(e.name, self.dicc) + if cub is None: + max_num += 1 + for e in files: + self.plan_archivo(e, nivel, anio, mes, ruta) + + def plan_subdir(self, e, nivel, anio, mes, padre, max_num): + nombre = e.name + nn = R.norm(nombre) + regla = self.regla_para(e.path) + if regla: + if regla.get("destino") in ("", None, "IGNORAR"): + return + self.agregar(e.path, self.abs_(regla["destino"]), regla.get("cubeta", "REVISAR"), "mover carpeta", + "regla aprendida", "alta") + return + if nn in R.CARPETAS_PROPIAS or nombre.startswith("_"): + return # estructura ya canónica + if R.es_basura_dir(nombre): + self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "BASURA", rel=self.rel(e.path))), + "BASURA", "papelera", "carpeta de caché / autoguardado", "alta") + return + if R.es_proxy_dir(nombre): + self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "PROXY", rel=self.rel(e.path))), + "PROXY", "papelera", "proxies: se regeneran desde Resolve/Premiere", "alta") + return + a = R.detectar_anio(nombre) + if a and nivel in ("raiz", "contenedor", "grabacion"): + if nivel == "raiz": + self.plan_dir(e.path, "anio", a, None) + else: + # un año dentro de un contenedor: mirar adentro con el año fijado + self.plan_dir(e.path, "contenedor", a, None) + return + if nivel == "anio": + m, conflicto = R.detectar_mes(nombre) + if m: + canon = R.nombre_mes(m) + if nombre != canon: + motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "") + self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo, "media" if conflicto else "alta") + self.plan_dir(e.path, "mes", anio, m) + return + if nivel == "raiz" or (nivel == "contenedor" and not anio): + # un mes suelto ("marzo", "Marzo 2026", "03- Marzo") fuera de su carpeta de año + sin_anio = re.sub(r"20\d\d", " ", nombre).strip(" -_") + m, conflicto = R.detectar_mes(sin_anio) + if m: + m_anio = re.search(r"(20\d\d)", nombre) + fa = int(m_anio.group(1)) if m_anio else fecha_carpeta(self.con, e.path)[0] + hoy = datetime.date.today() + if fa and (fa, m) > (hoy.year, hoy.month): + fa -= 1 + if fa: + canon = self.abs_(os.path.join(str(fa), R.nombre_mes(m))) + self.agregar(e.path, canon, "MES", "mover carpeta", + f"mes suelto fuera de su año: entra en {fa}", "media") + self.plan_dir(e.path, "mes", fa, m) + return + if nivel == "contenedor" and anio: + m, conflicto = R.detectar_mes(nombre) + if m: + # un mes guardado dentro de "Nuevo 2026": sale al año que le toca + canon = self.abs_(os.path.join(str(anio), R.nombre_mes(m))) + self.agregar(e.path, canon, "MES", "mover carpeta", f"mes guardado dentro de «{os.path.basename(padre)}»", "media") + self.plan_dir(e.path, "mes", anio, m) + return + if nivel == "mes": + p = R.detectar_pieza(nombre) + if p: + cub_t, _ = R.cubeta_carpeta(p[1], self.dicc) + if cub_t == "GRABACION": # "03 - Grabacion Agosto": es la sesión del mes, no una pieza + self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación") + return + canon = R.nombre_pieza(p[0], limpiar_nombre(p[1])) + if nombre != canon: + self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar", + "cosmético: nombre de pieza a «NN- Título» (sin marcar por defecto)", "media", marcado=False) + return # dentro de una pieza no se toca + cub, motivo = R.cubeta_carpeta(nombre, self.dicc) + if cub == "IGNORAR": + return + if cub == "CONTENEDOR": + m_anio = re.search(r"(20\d\d)", nombre) + self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else anio, mes) + return + if cub == "REVISAR": + self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "REVISAR", nombre=nombre)), "REVISAR", + "mover carpeta", motivo, "baja") + return + if cub == "GRABACION": + self.plan_grabacion(e, nivel, anio, mes, motivo) + return + generico = nn in self.dicc + limpio = limpiar_nombre(nombre) + p = R.detectar_pieza(nombre) + if p and cub in ("CAMPANA", "PIEZA", None): + limpio = limpiar_nombre(p[1]) # "01- Dove" -> "Dove" + sub = "" if generico else limpio + fa, fm, fconf, fmot = fecha_carpeta(self.con, e.path) + con_fecha = cub in ("PIEZA", "CAMPANA", "EXPORTADOS", "PORTADAS", "VOZ", "IA", "GUION", "FOTOS", "CELULAR", None) + if anio: + fa = anio + if mes: + fm = mes + elif fconf == "baja" and cub not in ("PIEZA", None): + fm = None # fechas mezcladas: se queda a nivel de año ("2025/_Exportados") + fconf = "media" if fa else "baja" + hoy = datetime.date.today() + if fa and fm and (fa, fm) > (hoy.year, hoy.month): + fa -= 1 # un mes en el futuro no existe: era del año pasado + if cub is None: + filas_c = [f for f in archivos_bajo(self.con, e.path) if not f["es_basura"] and not f["es_proxy"]] + cam = [f for f in filas_c if f["fuente"] in R.FUENTE_CARPETA + or f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado")] + if filas_c and len(cam) >= 0.8 * len(filas_c): + self.plan_grabacion(e, nivel, anio, mes, "carpeta sin nombre reconocido pero llena de clips de cámara") + return + if nivel == "mes": + canon = R.nombre_pieza(max_num + 1, limpio) + self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar", + "carpeta sin número dentro del mes: se numera como pieza", "media") + return + cub = "PIEZA" + motivo = "no reconocida: se propone como pieza por la fecha de sus archivos" + fconf = "baja" + sub = limpio + if con_fecha and not (anio and mes): + conf = fconf + motivo = f"{motivo}; {fmot}" + else: + conf = "alta" if generico else "media" + if cub == "MARCA": + sub = R.MARCA_SUB.get(nn, sub or limpio) + conf = "alta" if generico or nn in R.MARCA_SUB else "media" + if cub in ("PIEZA", "CAMPANA", "PROYECTOS"): + sub = sub or limpio + destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path)) + self.agregar(e.path, self.abs_(destino), cub, "mover carpeta", motivo, conf) + + def plan_grabacion(self, e, nivel, anio, mes, motivo): + """Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto).""" + nombre = e.name + nn = R.norm(nombre) + generico = nn in self.dicc or nn in ("grabacion", "grabado", "grabaciones") + filas = archivos_bajo(self.con, e.path) + if not filas: + return + grupos = defaultdict(list) + for f in filas: + if f["es_basura"] or f["es_proxy"]: + continue + fuente = R.FUENTE_CARPETA.get(f["fuente"], None) + if fuente is None: + if f["rol"] in ("audio", "audio_grabado"): + fuente = "AUDIO" + elif f["rol"] in ("video", "camara_video"): + fuente = "OTROS" + elif f["rol"] in ("imagen", "foto_camara", "celular_foto"): + fuente = "FOTOS" + else: + fuente = "OTROS" + # año / mes que digan las carpetas intermedias (p. ej. "Blackmagic (BRAW)/2023/…") + anio_ruta, mes_ruta = None, None + for seg in os.path.relpath(f["dir"], e.path).split(os.sep): + if R.detectar_anio(seg): + anio_ruta = R.detectar_anio(seg) + elif R.detectar_mes(seg)[0]: + mes_ruta = R.detectar_mes(seg)[0] + if anio and mes: + ay, am, fconf = anio, mes, "alta" + else: + ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) + por_nombre = f["fecha_origen"] != "mtime" + fconf = "media" if por_nombre else "baja" + # el año de una carpeta intermedia ("…/2023/…") manda sobre la fecha de modificación, + # pero no sobre una fecha escrita en el nombre del archivo + if anio_ruta and not por_nombre: + ay = anio_ruta + fconf = "media" + if anio and not por_nombre: + ay = anio + if mes_ruta: + am = mes_ruta + fconf = "media" + hoy = datetime.date.today() + if (ay, am) > (hoy.year, hoy.month): + ay -= 1 + grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"])) + etiqueta = "" if generico else R.etiqueta_grabacion(nombre) + for (ay, am, fuente, fconf), archivos in grupos.items(): + sub = fuente if not etiqueta else os.path.join(etiqueta, fuente) + destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, sub=sub, nombre=limpiar_nombre(nombre)) + # si todos ya están en esa carpeta, no hay nada que hacer + if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos): + continue + mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else "")) + self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot, fconf, archivos=archivos) + + def plan_archivo(self, e, nivel, anio, mes, padre): + fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone() + if not fila: + return + if fila["es_basura"] or fila["es_proxy"]: + return # los recoge plan_basura + if nivel == "pieza": + return + rol = fila["rol"] + cub = R.ROL_A_CUBETA.get(rol, "REVISAR") + conf = "media" + motivo = f"archivo suelto ({rol})" + fa, fm = int(fila["fecha"][:4]), int(fila["fecha"][5:7]) + if anio and fila["fecha_origen"] == "mtime": + fa = anio + if mes: + fm = mes + elif fila["fecha_origen"] == "mtime": + conf = "baja" + motivo += " · mes por fecha de modificación" + hoy = datetime.date.today() + if (fa, fm) > (hoy.year, hoy.month): + fa -= 1 + sub = "" + if cub == "GRABACION": + sub = R.FUENTE_CARPETA.get(fila["fuente"], "OTROS") + conf = "alta" if (anio and mes) else conf + if cub == "CELULAR": + if self.tipo == "contenido": + cub = "GRABACION" + sub = "CELULAR" + else: + sub = fila["fecha"][:7] if not (anio and mes) else "" + if cub == "PORTADAS" and nivel not in ("mes",): + cub = "REVISAR" if rol == "imagen" and nivel == "raiz" else cub + conf = "baja" if cub == "REVISAR" else conf + if cub == "EXPORTADOS": + conf = "media" if nivel == "mes" else "baja" + motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "") + if cub == "PROYECTOS": + sub = {"diseno": "Diseño", "proyecto": "Edicion"}.get(rol, "") + motivo = "proyecto de edición / diseño suelto" + conf = "alta" + if cub == "MUSICA" and rol == "audio": + conf = "baja" + if cub == "REVISAR": + destino = "_Revisar" + elif cub == "PROYECTOS": + destino = destino_plantilla(self.tipo, "PROYECTOS", nombre=sub) + else: + destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="") + self.suelto(fila, cub, destino, motivo, conf) + + def plan_basura(self): + con = self.con + # carpetas enteras de basura / proxy ya salieron en plan_subdir si estaban a la vista; + # aquí van las que están más adentro (dentro de piezas) y los archivos sueltos de basura + carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall() + cubiertas = [] + for c in sorted(carpetas, key=lambda c: len(c["ruta"])): + if any(c["ruta"].lower().startswith(p.lower() + os.sep) for p in cubiertas): + continue + if any(os.path.normpath(f["origen"]).lower() == c["ruta"].lower() for f in self.filas): + cubiertas.append(c["ruta"]) + continue + if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS): + continue + cub = "PROXY" if c["es_proxy"] else "BASURA" + self.agregar(c["ruta"], self.abs_(destino_plantilla(self.tipo, cub, rel=self.rel(c["ruta"]))), cub, "papelera", + "proxies: se regeneran" if cub == "PROXY" else "caché / autoguardado", "alta") + cubiertas.append(c["ruta"]) + sueltos = con.execute("SELECT * FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall() + grupos = defaultdict(list) + for f in sueltos: + if any(f["dir"].lower() == p.lower() or f["dir"].lower().startswith(p.lower() + os.sep) for p in cubiertas): + continue + if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS): + continue + cub = "PROXY" if f["es_proxy"] else "BASURA" + grupos[cub].append(f) + # una sola fila por tipo: cada archivo conserva su ruta relativa dentro de _Para borrar + for cub, fs in grupos.items(): + destinos = {} + for f in fs: + destinos[f["ruta"]] = self.abs_(os.path.join(destino_plantilla(self.tipo, cub, rel=self.rel(f["dir"])), f["nombre"])) + archivos = [(f["ruta"], f["bytes"]) for f in fs] + self.agregar(self.raiz, self.abs_(destino_plantilla(self.tipo, cub, rel="")), cub, "papelera", + f"basura de sistema en {len(set(f['dir'] for f in fs))} carpetas (.DS_Store, Thumbs.db, .tmp, ._*)" if cub == "BASURA" else "proxies sueltos", + "alta", archivos=archivos) + self.filas[-1]["destinos_archivo"] = destinos + self.filas[-1]["origen_rel"] = "(toda la raíz)" + + +# ========================================================================== +# Aplicar el plan (con bitácora y deshacer) +# ========================================================================== + +def bitacora(entrada): + entrada["ts"] = time.time() + with open(BITACORA_PATH, "a", encoding="utf-8") as f: + f.write(json.dumps(entrada, ensure_ascii=False) + "\n") + + +def leer_bitacora(limite=2000): + if not os.path.exists(BITACORA_PATH): + return [] + with open(BITACORA_PATH, "r", encoding="utf-8") as f: + lineas = f.readlines() + out = [] + for l in lineas[-limite:]: + try: + out.append(json.loads(l)) + except ValueError: + pass + return out + + +def nombre_libre(destino): + if not os.path.exists(destino): + return destino + base, ext = os.path.splitext(destino) + i = 2 + while os.path.exists(f"{base} ({i}){ext}"): + i += 1 + return f"{base} ({i}){ext}" + + +def mover(origen, destino, lote, movidos): + """mueve archivo o carpeta; si la carpeta destino existe, fusiona""" + origen = os.path.normpath(origen) + destino = os.path.normpath(destino) + if origen.lower() == destino.lower(): + return + if os.path.isdir(origen) and os.path.isdir(destino): + for nombre in os.listdir(origen): + mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos) + try: + os.rmdir(origen) + bitacora({"lote": lote, "op": "rmdir", "de": origen}) + except OSError: + pass + return + os.makedirs(os.path.dirname(destino), exist_ok=True) + if os.path.exists(destino): + # dentro de _Para borrar o Duplicados el nombre da igual: se numera. Fuera, NUNCA se renombra + # (Resolve/Premiere enlazan por nombre y los sidecar Sony van emparejados): se deja donde está y se avisa. + if _fuera_de_dup(destino): + raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo") + destino = nombre_libre(destino) + try: + os.rename(origen, destino) + 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): + shutil.move(origen, destino) + else: + shutil.copy2(origen, destino) + if os.path.getsize(destino) != os.path.getsize(origen): + os.remove(destino) + raise OSError(f"la copia de «{os.path.basename(origen)}» quedó incompleta; el original no se toca") + try: + os.remove(origen) + except OSError as ex: + os.remove(destino) + raise OSError(f"«{os.path.basename(origen)}» está en uso (¿abierto en Resolve o Premiere?); no se mueve") from ex + bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino}) + movidos.append((origen, destino)) + + +APLICANDO = {"hechas": 0, "total": 0, "actual": ""} + + +def aplicar(raiz, filas): + lote = time.strftime("%Y%m%d-%H%M%S") + movidos = [] + errores = [] + hechas = 0 + 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())) + APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz)) + for f in filas: + APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "") + try: + if f.get("archivos"): + especiales = f.get("destinos_archivo") or {} + if not especiales: + os.makedirs(f["destino"], exist_ok=True) + for a in f["archivos"]: + if os.path.exists(a): + mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos) + else: + if os.path.exists(f["origen"]): + mover(f["origen"], f["destino"], lote, movidos) + hechas += 1 + except Exception as ex: # noqa + errores.append(f"{f.get('origen_rel')}: {ex}") + # carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos) + for f in filas: + base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"]) + if os.path.normpath(base).lower() != os.path.normpath(raiz).lower(): + quitar_vacias(base, raiz) + actualizar_inventario(movidos) + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores}) + return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores} + + +def actualizar_inventario(movidos): + """refleja en la base de datos los movimientos hechos, sin re-escanear""" + con = db() + marcas = [os.sep + p + os.sep for p in R.PAPELERAS] + with con: + 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 = ?, es_basura = CASE WHEN ? = 1 THEN 1 WHEN rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?", + (a, os.path.dirname(a), os.path.basename(a), en_papelera, de)) + pref = de + os.sep + for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).fetchall(): + nueva = a + r["ruta"][len(de):] + con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = CASE WHEN ? = 1 THEN 1 WHEN rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?", + (nueva, os.path.dirname(nueva), en_papelera, r["ruta"])) + con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%")) + con.close() + + +def quitar_vacias(ruta, tope): + """borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)""" + ruta = os.path.normpath(ruta) + tope = os.path.normpath(tope) + while ruta.lower() != tope.lower() and ruta.lower().startswith(tope.lower()): + try: + if os.path.isdir(ruta) and not os.listdir(ruta): + os.rmdir(ruta) + else: + break + except OSError: + break + ruta = os.path.dirname(ruta) + + +def deshacer(lote): + todas = leer_bitacora(100000) + entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir")] + fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {}) + raiz = fin.get("raiz") or "" + deshechos, errores, movidos = 0, [], [] + for e in reversed(entradas): + try: + if e["op"] == "rmdir": + os.makedirs(e["de"], exist_ok=True) + else: + if not os.path.exists(e["a"]): + errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve") + elif os.path.exists(e["de"]): + errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve") + else: + os.makedirs(os.path.dirname(e["de"]), exist_ok=True) + try: + os.rename(e["a"], e["de"]) + except OSError: + shutil.move(e["a"], e["de"]) # el respaldo copió entre discos: se vuelve de la misma forma + movidos.append((e["a"], e["de"])) + deshechos += 1 + if raiz: + quitar_vacias(os.path.dirname(e["a"]), raiz) + except OSError as ex: + errores.append(f"{e.get('a')}: {ex}") + actualizar_inventario(movidos) + bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores}) + return {"deshechos": deshechos, "errores": errores} + + +# ========================================================================== +# Vocabulario (lo que la app "ve" que usamos como nombres) +# ========================================================================== + +def vocabulario(raiz): + con = db() + filas = con.execute("SELECT * FROM carpetas WHERE raiz = ?", (raiz,)).fetchall() + con.close() + tokens = {} + for c in filas: + nombre = c["nombre"] + if os.path.normpath(c["ruta"]).lower() == os.path.normpath(raiz).lower(): + continue + if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]: + continue + rel = os.path.relpath(c["ruta"], raiz) + padre = os.path.basename(os.path.dirname(c["ruta"])) + if R.detectar_pieza(nombre) and R.detectar_mes(padre)[0]: + continue + if R.norm(nombre) in R.CARPETAS_PROPIAS or nombre.startswith("_"): + continue + tok = R.norm(nombre) + if not tok or len(tok) > 60: + continue + # ¿está dentro de una pieza (carpeta numerada bajo un mes)? esas no se mueven + partes = rel.split(os.sep)[:-1] + dentro_pieza = any(R.detectar_pieza(partes[i]) and i > 0 and R.detectar_mes(partes[i - 1])[0] for i in range(len(partes))) + t = tokens.get(tok) + if t is None: + cub, motivo = R.cubeta_carpeta(nombre, DICC["carpetas"]) + t = tokens[tok] = {"token": tok, "ejemplo": nombre, "n": 0, "archivos": 0, "bytes": 0, "rutas": [], "dentro_pieza": dentro_pieza, + "cubeta": cub or "", "motivo": motivo, "aprendido": tok in DICC["carpetas"] and tok not in R.DICCIONARIO_SEMILLA} + t["dentro_pieza"] = t["dentro_pieza"] and dentro_pieza + t["n"] += 1 + t["archivos"] += c["n"] or 0 + t["bytes"] += c["bytes"] or 0 + if len(t["rutas"]) < 4: + t["rutas"].append(rel) + out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) + return out + + +# ========================================================================== +# Mapa +# ========================================================================== + +def mapa(raiz): + con = db() + raiz = os.path.normpath(raiz) + esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() + if not esc: + con.close() + return {"escaneado": False} + q = lambda sql, *a: [dict(r) for r in con.execute(sql, a).fetchall()] + total = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ?", raiz)[0] + por_ext = q("SELECT ext, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY ext ORDER BY bytes DESC LIMIT 14", raiz) + por_rol = q("SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY rol ORDER BY bytes DESC", raiz) + por_fuente = q("SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND fuente != '' GROUP BY fuente ORDER BY bytes DESC", raiz) + por_anio = q("SELECT substr(fecha,1,4) anio, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY anio ORDER BY anio", raiz) + por_mes = q("SELECT substr(fecha,1,7) mes, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado') GROUP BY mes ORDER BY mes", raiz) + basura = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_basura = 1", raiz)[0] + proxies = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", raiz)[0] + dd = duplicados(raiz) + dup_ej = [{"tipo": g["tipo"], "nombre": g["nombre"], "bytes": g["bytes"], "copias": len(g["mueven"]) + 1, + "queda": os.path.relpath(g["queda"], raiz), "rutas": [os.path.relpath(r, raiz) for r in g["mueven"]]} for g in dd["grupos"][:12]] + raw = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND rol = 'camara_raw'", raiz)[0] + grandes = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND bytes > 1000000000 AND rol IN ('video','camara_video') AND es_proxy = 0", raiz)[0] + sin_fecha = q("SELECT COUNT(*) n FROM archivos WHERE raiz = ? AND fecha_origen = 'mtime' AND rol IN ('camara_raw','camara_video')", raiz)[0] + # árbol de 2 niveles para el treemap + hijos = q("SELECT * FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC", + raiz, esc_like(con_sep(raiz)) + "%", esc_like(con_sep(raiz)) + "%" + esc_like(os.sep) + "%") + arbol = [] + for h in hijos: + nietos = q("SELECT nombre, n, bytes FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC LIMIT 40", + raiz, esc_like(h["ruta"] + os.sep) + "%", esc_like(h["ruta"] + os.sep) + "%" + esc_like(os.sep) + "%") + cub, _ = R.cubeta_carpeta(h["nombre"], DICC["carpetas"]) + if R.detectar_anio(h["nombre"]): + cub = "ANIO" + elif R.norm(h["nombre"]) in R.PAPELERAS_NORM or R.norm(h["nombre"]) == R.norm(R.DUPLICADOS): + cub = "APARTADO" # carpetas de la propia app: no son «sin reconocer» + arbol.append({"nombre": h["nombre"], "n": h["n"], "bytes": h["bytes"], "cubeta": cub or "", "hijos": nietos, + "anio": h["anio_moda"], "mes": h["mes_moda"]}) + sueltos_raiz = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, raiz)[0] + con.close() + return { + "escaneado": True, "raiz": raiz, "escaneo": dict(esc), "total": total, "por_ext": por_ext, "por_rol": por_rol, + "por_fuente": por_fuente, "por_anio": por_anio, "por_mes": por_mes, "basura": basura, "proxies": proxies, + "duplicados": {"grupos": len(dd["grupos"]), "n": dd["n"], "bytes": dd["bytes"], "identicos": dd["identicos"], "versiones": dd["versiones"], "ejemplos": dup_ej}, "raw": raw, "grandes": grandes, + "sin_fecha": sin_fecha, "arbol": arbol, "sueltos_raiz": sueltos_raiz, + } + + +def buscar_carpeta(nombre, profundidad=3): + """Busca una carpeta por nombre en todos los discos (hasta 3 niveles), para el arrastrar-y-soltar.""" + objetivo = R.norm(nombre) + if not objetivo: + return [] + saltar = {"#recycle", "@eadir", "windows", "program files", "program files (x86)", "programdata", "$recycle.bin", "appdata", "node_modules", "system volume information"} + hits = [] + inicio = time.time() + + def rec(path, nivel): + if time.time() - inicio > 25 or len(hits) >= 20: + return + try: + with os.scandir(path) as it: + ents = [e for e in it if e.is_dir(follow_symlinks=False) and not e.name.startswith((".", "$", "~"))] + except OSError: + return + for e in ents: + if R.norm(e.name) == objetivo: + hits.append(e.path) + if nivel < profundidad: + for e in ents: + if e.name.lower() not in saltar: + rec(e.path, nivel + 1) + + for letra in "DEFGHIJKLMNOPQRSTUVWXYZC": + raiz = letra + ":" + os.sep + if os.path.exists(raiz): + rec(raiz, 1) + return hits + + +_DISCOS_CACHE = {"ts": 0, "datos": None} + + +def discos(): + if time.time() - _DISCOS_CACHE["ts"] < 120 and _DISCOS_CACHE["datos"]: + return _DISCOS_CACHE["datos"] + unc = {} + try: + out = subprocess.run(["net", "use"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA).stdout + for l in out.splitlines(): + m = re.search(r"\b([A-Z]):\s+(\\\\\S+)", l) + if m: + unc[m.group(1) + ":"] = m.group(2) + except Exception: # noqa + pass + alias = {} + for cli, info in CONFIG["clientes"].items(): + for a in info.get("alias", []) + [cli]: + alias[R.norm(a)] = cli + res = [] + for letra in "CDEFGHIJKLMNOPQRSTUVWXYZ": + d = letra + ":" + raiz = d + os.sep + if not os.path.exists(raiz): + continue + try: + u = shutil.disk_usage(raiz) + except OSError: + continue + carpetas = [] + try: + with os.scandir(raiz) as it: + for e in it: + if e.is_dir(follow_symlinks=False) and not e.name.startswith(("#", ".", "$", "@")): + cli = alias.get(R.norm(e.name)) + carpetas.append({"nombre": e.name, "cliente": cli}) + except OSError: + pass + cfg = CONFIG["discos"].get(d, {}) + res.append({"letra": d, "unc": unc.get(d, ""), "nombre": cfg.get("nombre", ""), "nivel": cfg.get("nivel", "" if unc.get(d) else "local"), + "total": u.total, "libre": u.free, "carpetas": sorted(carpetas, key=lambda c: c["nombre"].lower())}) + # dónde vive cada cliente + por_cliente = defaultdict(list) + for disco in res: + for c in disco["carpetas"]: + if c["cliente"]: + por_cliente[c["cliente"]].append(f"{disco['letra']}\\{c['nombre']}") + for r in CONFIG["raices"]: + por_cliente[r["cliente"]].append(r["ruta"]) + datos = {"discos": res, "clientes": {k: sorted(set(v)) for k, v in por_cliente.items()}} + _DISCOS_CACHE.update(ts=time.time(), datos=datos) + return datos + + +# ========================================================================== +# Optimización: análisis (ffprobe), candidatos, cola, motores +# ========================================================================== + +ANALISIS = {} # raiz -> progreso + + +def ffprobe(ruta): + cmd = [ruta_herramienta("ffprobe"), "-v", "error", "-show_entries", + "stream=codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,bit_rate,nb_frames", + "-show_entries", "format=duration,bit_rate", "-of", "json", ruta] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=120, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA).stdout + j = json.loads(out or "{}") + except Exception: # noqa + return None + v = next((s for s in j.get("streams", []) if s.get("codec_type") == "video"), None) + a = next((s for s in j.get("streams", []) if s.get("codec_type") == "audio"), None) + fmt = j.get("format", {}) + if not v: + return None + fr = v.get("r_frame_rate", "0/1") + try: + num, den = fr.split("/") + fps = float(num) / float(den) if float(den) else 0 + except ValueError: + fps = 0 + dur = float(fmt.get("duration") or 0) + kbps = int(int(fmt.get("bit_rate") or v.get("bit_rate") or 0) / 1000) + pix = v.get("pix_fmt") or "" + return { + "codec": v.get("codec_name") or "", "perfil": v.get("profile") or "", "pix_fmt": pix, + "ancho": int(v.get("width") or 0), "alto": int(v.get("height") or 0), "fps": round(fps, 3), "dur": dur, + "kbps": kbps, "alfa": 1 if ("a" in pix and pix not in ("yuva", "") and ("rgba" in pix or "argb" in pix or "bgra" in pix or "yuva" in pix)) else 0, + "acodec": (a or {}).get("codec_name") or "", + } + + +def analizar(raiz, min_mb): + prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time()} + con = db() + pend = con.execute("SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado = 0 AND es_proxy = 0 AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock')", + (raiz, int(min_mb) * 1024 * 1024)).fetchall() + prog["total"] = len(pend) + try: + for f in pend: + info = ffprobe(f["ruta"]) + with con: + if info: + if f["ext"] == ".braw": + info["codec"] = "braw" + con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, analizado=1 WHERE ruta=?", + (info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], f["ruta"])) + else: + con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (f["ruta"],)) + prog["hechos"] += 1 + prog["estado"] = "listo" + except Exception as ex: # noqa + prog["estado"] = "error" + prog["error"] = str(ex) + finally: + con.close() + + +def kbps_objetivo(ancho, alto, fps, perfil): + bpp = PERFILES.get(perfil, PERFILES["equilibrado"])["bpp"] + fps = fps or 30 + return max(1500, int(ancho * alto * fps * bpp / 1000)) + + +def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False): + con = db() + limite = time.time() - int(meses) * 30.4 * 86400 + filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC", + (raiz, int(min_mb) * 1024 * 1024)).fetchall() + en_cola = {r["ruta"]: r["estado"] for r in con.execute("SELECT ruta, estado FROM trabajos").fetchall()} + con.close() + out = [] + for f in filas: + motivo_no = "" + motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" + if f["es_stock"]: + motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)" + elif f["alfa"]: + motivo_no = "tiene canal alfa (HEVC lo perdería)" + elif f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * 1.3: + motivo_no = "ya está en HEVC/AV1 eficiente" + elif f["mtime"] > limite: + motivo_no = f"tiene menos de {meses} meses" + elif f["contexto"] == "EXPORTADOS" and not incluir_exportados: + motivo_no = "es un exportado / master" + elif not f["ancho"] or not f["dur"]: + motivo_no = "no se pudo leer (ffprobe)" + obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) + est = int(obj * 1000 / 8 * (f["dur"] or 0)) + int(256000 / 8 * (f["dur"] or 0)) + out.append({ + "ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"], + "pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"], + "kbps_objetivo": obj, "estimado": est, "motor": motor, "excluido": motivo_no, "fecha": f["fecha"], "rol": f["rol"], + "contexto": f["contexto"], "en_cola": en_cola.get(f["ruta"], ""), + }) + return out + + +def salida_para(ruta, raiz): + o = CONFIG["optimizar"] + base, _ = os.path.splitext(ruta) + if o.get("salida") == "espejo": + rel = os.path.relpath(base, raiz) + return os.path.join(raiz, "_Archivo", rel + ".mp4") + return base + o.get("sufijo", "_10bit") + ".mp4" + + +def encolar(rutas, perfil, chroma, color, raiz): + con = db() + ids = [] + with con: + for r in rutas: + f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone() + if not f: + continue + ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r,)).fetchone() + if ya: + continue + salida = salida_para(r, raiz) + # el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola. + otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone() + if otro or os.path.exists(salida): + continue + motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" + kb = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) + cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz)) + ids.append(cur.lastrowid) + con.close() + return ids + + +COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": ""} + + +def set_trabajo(id_, **kw): + con = db() + with con: + sets = ", ".join(f"{k} = ?" for k in kw) + con.execute(f"UPDATE trabajos SET {sets} WHERE id = ?", (*kw.values(), id_)) + con.close() + + +def correr_ffmpeg(t): + o = CONFIG["optimizar"] + con = db() + f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + con.close() + dur = (f["dur"] if f else 0) or 0 + fps = (f["fps"] if f else 30) or 30 + chroma = t["chroma"] or "420" + cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"] + maxrate = int(t["kbps"] * 1.8) + salida = t["salida"] + os.makedirs(os.path.dirname(salida), exist_ok=True) + tmp = salida + ".parcial.mp4" + cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?", + "-c:v", "hevc_nvenc", + "-pix_fmt", "p210le" if chroma == "422" else "p010le", + "-profile:v", "rext" if chroma == "422" else "main10", + "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0", + "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", + "-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32", + "-bf", "3", "-b_ref_mode", "middle", "-g", str(int(round(fps * 2))), + "-tag:v", "hvc1", "-map_metadata", "0", "-movflags", "+faststart"] + if f and f["acodec"] == "aac": + cmd += ["-c:a", "copy"] + else: + cmd += ["-c:a", "aac", "-b:a", "320k"] + cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp] + set_trabajo(t["id"], log=" ".join(cmd)[:4000]) + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) + except OSError as ex: + set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}") + return + ultimo = 0 + for linea in p.stdout: + if COLA["cancelar"]: + p.kill() + break + if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="): + try: + us = int(linea.split("=")[1]) + if linea.startswith("out_time_ms="): + us = us # ffmpeg reporta microsegundos en ambas claves + pct = min(99.0, us / 1e6 / dur * 100) if dur else 0 + if time.time() - ultimo > 1: + set_trabajo(t["id"], pct=pct) + ultimo = time.time() + except ValueError: + pass + err = p.stderr.read() + p.wait() + if COLA["cancelar"]: + set_trabajo(t["id"], estado="cancelado", pct=0) + try: + os.remove(tmp) + except OSError: + pass + return + if p.returncode != 0 or not os.path.exists(tmp): + set_trabajo(t["id"], estado="error", log=(err or "")[-3000:]) + return + os.replace(tmp, salida) + try: + st = os.stat(t["ruta"]) + os.utime(salida, (st.st_atime, st.st_mtime)) + except OSError: + pass + set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time()) + + +def correr_resolve(trabajos): + """Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte).""" + o = CONFIG["optimizar"] + con = db() + jobs = [] + for t in trabajos: + f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + jobs.append({"id": t["id"], "src": t["ruta"], "dst": t["salida"], "w": (f["ancho"] if f else 0) or 0, + "h": (f["alto"] if f else 0) or 0, "fps": (f["fps"] if f else 0) or 0, "kbps": t["kbps"], + "color": t["color"] or "rec709"}) + set_trabajo(t["id"], estado="corriendo", pct=0) + con.close() + lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json") + with open(lote, "w", encoding="utf-8") as fh: + json.dump(jobs, fh, ensure_ascii=False) + if CONGELADO and not o.get("python_resolve"): + cmd = [sys.executable, "--puente", lote] # el mismo .exe hace de puente + else: + py = o.get("python_resolve") or sys.executable + cmd = [py, os.path.join(BASE, "resolve_bridge.py"), lote] + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) + except OSError as ex: + for t in trabajos: + set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el puente de Resolve: {ex}") + return + log_general = [] + for linea in p.stdout: + linea = linea.strip() + if not linea: + continue + if linea.startswith("{"): + try: + ev = json.loads(linea) + except ValueError: + log_general.append(linea) + continue + if "id" in ev: + kw = {} + if "pct" in ev: + kw["pct"] = ev["pct"] + if ev.get("estado"): + kw["estado"] = ev["estado"] + if ev.get("log"): + kw["log"] = ev["log"][-3000:] + if ev.get("estado") == "listo": + kw["fin"] = time.time() + try: + kw["bytes_out"] = os.path.getsize(ev.get("salida") or "") + except OSError: + pass + set_trabajo(ev["id"], **kw) + else: + COLA["mensaje"] = ev.get("msg", "") + log_general.append(ev.get("msg", "")) + else: + log_general.append(linea) + if COLA["cancelar"]: + p.kill() + break + p.wait() + 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") + con.close() + + +def _verificar_listos(ids): + """al acabar cada conversión se verifica sola (códec, 10 bits, duración): el usuario solo decide si reemplaza""" + try: + con = db() + listos = [r["id"] for r in con.execute(f"SELECT id FROM trabajos WHERE estado = 'listo' AND id IN ({','.join('?' * len(ids))})", ids).fetchall()] if ids else [] + con.close() + if listos: + verificar(listos) + except Exception as ex: # noqa + COLA["mensaje"] = f"no se pudo verificar: {ex}" + + +def bucle_cola(): + try: + while COLA["activa"]: + con = db() + t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'ffmpeg' ORDER BY id LIMIT 1").fetchone() + lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()] + con.close() + if t: + COLA["actual"] = t["id"] + set_trabajo(t["id"], estado="corriendo", pct=0) + try: + correr_ffmpeg(dict(t)) + except Exception as ex: # noqa + set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}") + _verificar_listos([t["id"]]) + elif lote_resolve: + COLA["actual"] = lote_resolve[0]["id"] + try: + correr_resolve(lote_resolve) + except Exception as ex: # noqa + for r in lote_resolve: + set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}") + _verificar_listos([r["id"] for r in lote_resolve]) + else: + break + COLA["actual"] = None + if COLA["cancelar"]: + COLA["cancelar"] = False + break + except Exception as ex: # noqa + COLA["mensaje"] = f"la cola se detuvo por un error: {ex}" + finally: + # pase lo que pase, la cola no queda «activa» para siempre (bloqueaba el cierre del programa) + COLA["activa"] = False + COLA["actual"] = None + COLA["hilo"] = None + + +def iniciar_cola(): + if COLA["hilo"] and COLA["hilo"].is_alive(): + return False + COLA["activa"] = True + COLA["cancelar"] = False + COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True) + COLA["hilo"].start() + return True + + +def verificar(ids): + con = db() + res = [] + for id_ in ids: + t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() + if not t or not os.path.exists(t["salida"]): + res.append({"id": id_, "ok": False, "motivo": "no existe la salida"}) + continue + f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + info = ffprobe(t["salida"]) + if not info: + res.append({"id": id_, "ok": False, "motivo": "ffprobe no pudo leer la salida"}) + set_trabajo(id_, estado="error", log="verificación: ffprobe no pudo leer la salida") + continue + dur_src = (f["dur"] if f else 0) or 0 + tol = max(0.5, dur_src * 0.01) + problemas = [] + if dur_src and abs(info["dur"] - dur_src) > tol: + problemas.append(f"duración {info['dur']:.2f}s vs original {dur_src:.2f}s") + if info["codec"] != "hevc": + problemas.append(f"códec {info['codec']}") + if "10" not in info["pix_fmt"]: + problemas.append(f"no es 10 bits ({info['pix_fmt']})") + if os.path.getsize(t["salida"]) < 100000: + problemas.append("archivo demasiado pequeño") + if problemas: + set_trabajo(id_, estado="error", log="verificación: " + "; ".join(problemas)) + res.append({"id": id_, "ok": False, "motivo": "; ".join(problemas)}) + else: + set_trabajo(id_, estado="verificado", bytes_out=os.path.getsize(t["salida"])) + res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"}) + con.close() + return res + + +def reemplazar(ids): + """El original se va a _Para borrar/originales (misma raíz). Nunca se borra.""" + 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 + movimientos, raiz_lote = 0, "" + for id_ in ids: + t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() + if not t or t["estado"] != "verificado": + res.append({"id": id_, "ok": False, "motivo": "solo se reemplazan trabajos verificados"}) + continue + if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024: + res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"}) + set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar") + continue + raiz = t["raiz"] + raiz_lote = raiz_lote or raiz + rel = os.path.relpath(t["ruta"], raiz) + destino = os.path.join(raiz, R.PAPELERA, "originales", rel) + try: + os.makedirs(os.path.dirname(destino), exist_ok=True) + destino = nombre_libre(destino) + os.rename(t["ruta"], destino) + movimientos += 1 + bitacora({"lote": lote, "op": "mover", "de": t["ruta"], "a": destino, "trabajo": id_}) + # el sidecar .xml de Sony y el .mp4 proxy de Blackmagic se van con el original + base, ext = os.path.splitext(t["ruta"]) + for extra in (base + "M01.xml", base + "M01.XML"): + if os.path.exists(extra): + d2 = os.path.join(os.path.dirname(destino), os.path.basename(extra)) + os.rename(extra, d2) + movimientos += 1 + bitacora({"lote": lote, "op": "mover", "de": extra, "a": d2, "trabajo": id_}) + set_trabajo(id_, estado="reemplazado") + with con: + con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = 1 WHERE ruta = ?", (destino, os.path.dirname(destino), t["ruta"])) + res.append({"id": id_, "ok": True, "motivo": f"original en {os.path.relpath(destino, raiz)}"}) + except OSError as ex: + res.append({"id": id_, "ok": False, "motivo": str(ex)}) + con.close() + if movimientos: + bitacora({"lote": lote, "op": "fin", "raiz": raiz_lote, "filas": len(ids), "movimientos": movimientos, + "errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"]]}) + return res + + +# ========================================================================== +# Stock de Envato en proyectos viejos, papelera y respaldo +# ========================================================================== + +def tipo_medio(rol, ext): + e = (ext or "").lower() + if e in R.EXT["video"] or e in R.EXT["video_raw"]: + return "Video" + if e in R.EXT["audio"]: + return "Audio" + if e in R.EXT["imagen"] or e in R.EXT["foto_raw"]: + return "Imagenes" + if e in R.EXT["diseno"]: + return "Diseño" + if e in R.EXT["proyecto"]: + return "Proyectos" + if e in R.EXT["documento"]: + return "Documentos" + if e in R.EXT["fuente"] or e in R.EXT["lut"]: + return "Fuentes" + return "Otros" + + +def limite_meses(meses): + hoy = datetime.date.today() + m = hoy.year * 12 + hoy.month - 1 - int(meses) + return m // 12, m % 12 + 1 # (año, mes) tope: lo anterior es "viejo" + + +def es_viejo(f, tope): + """un archivo es viejo si el mes de su pieza (o su fecha) es anterior al tope""" + if f["anio_ruta"] and f["mes_ruta"]: + return (f["anio_ruta"], f["mes_ruta"]) < tope + return (int(f["fecha"][:4]), int(f["fecha"][5:7])) < tope + + +def stock_viejo(raiz, meses, tipos): + con = db() + tope = limite_meses(meses) + filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0", (raiz,)).fetchall() + con.close() + grupos = {} + for f in filas: + t = tipo_medio(f["rol"], f["ext"]) + if t not in tipos and not (t == "Otros" and f["ext"] in (".zip", ".rar") and "Video" in tipos): + continue + if not es_viejo(f, tope): + continue + rel = os.path.relpath(f["dir"], raiz) + g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "mes": f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]}) + g["n"] += 1 + g["bytes"] += f["bytes"] + g["rutas"].append(f["ruta"]) + if len(g["ejemplos"]) < 3: + g["ejemplos"].append(f["nombre"]) + out = sorted(grupos.values(), key=lambda g: -g["bytes"]) + return {"grupos": out, "n": sum(g["n"] for g in out), "bytes": sum(g["bytes"] for g in out), "tope": f"{tope[0]}-{tope[1]:02d}"} + + +def stock_a_papelera(raiz, rutas): + lote = "stock-" + time.strftime("%Y%m%d-%H%M%S") + movidos, errores = [], [] + for r in rutas: + try: + if not os.path.exists(r): + continue + if not dentro_de(raiz, r): + errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca") + continue + rel = os.path.relpath(r, raiz) + mover(r, os.path.join(raiz, R.PAPELERA, "stock", rel), lote, movidos) + quitar_vacias(os.path.dirname(r), raiz) + except Exception as ex: # noqa + errores.append(f"{r}: {ex}") + actualizar_inventario(movidos) + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(rutas), "movimientos": len(movidos), "errores": errores}) + return {"lote": lote, "movimientos": len(movidos), "errores": errores} + + +def papelera(raiz): + """Qué hay en _Para borrar (y en la _Papelera vieja si existe). Solo lee: borrar es cosa de una persona.""" + out = [] + for carpeta in R.PAPELERAS: + base = os.path.join(raiz, carpeta) + if not os.path.isdir(base): + continue + for nombre in sorted(os.listdir(base)): + p = os.path.join(base, nombre) + if not os.path.isdir(p): + continue + n, b = 0, 0 + for d, _, fs in os.walk(p): + for f in fs: + try: + b += os.path.getsize(os.path.join(d, f)) + n += 1 + except OSError: + pass + out.append({"carpeta": carpeta, "nombre": nombre, "ruta": p, "n": n, "bytes": b}) + return {"subcarpetas": out, "bytes": sum(x["bytes"] for x in out), "carpeta": os.path.join(raiz, R.PAPELERA)} + + +def abrir_carpeta(ruta): + """Abre una carpeta en el Explorador (solo dentro de alguna raíz configurada).""" + ruta = os.path.normpath(ruta) + permitido = any(ruta.lower().startswith(os.path.normpath(r["ruta"]).lower()) for r in CONFIG.get("raices", [])) + if not permitido or not os.path.isdir(ruta): + return {"ok": False, "motivo": "esa carpeta no está dentro de una raíz"} + os.startfile(ruta) + return {"ok": True} + + +# ========================================================================== +# Duplicados: se juntan en una subcarpeta «Duplicados» junto al que se queda. Nunca se borran. +# ========================================================================== +_EXT_VIDEO_DUP = {".mp4", ".mov", ".mxf", ".braw", ".avi", ".mkv", ".m4v", ".mts", ".r3d"} + + +def _fuera_de_dup(ruta): + partes = {R.norm(p) for p in os.path.normpath(ruta).split(os.sep)} + return not (partes & (R.PAPELERAS_NORM | {R.norm(R.DUPLICADOS)})) + + +def _mejor(rutas): + """Cuál se queda: el que ya esté en una carpeta de grabación, luego el de ruta más corta.""" + def clave(r): + en_grab = 0 if "grabacion" in R.norm(r.replace(os.sep, " ")) else 1 + return (en_grab, len(r.split(os.sep)), len(r), r.lower()) + return sorted(rutas, key=clave)[0] + + +def duplicados(raiz, minimo=5_000_000): + """Grupos de archivos repetidos. Dos clases: + identico = mismo nombre y mismo tamaño en sitios distintos. + version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede + cambiar el color o el peso, así que se deja para revisar a mano.""" + con = db() + filas = [dict(r) for r in con.execute( + "SELECT ruta, dir, nombre, ext, bytes FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0", + (os.path.normpath(raiz), minimo)).fetchall()] + con.close() + filas = [f for f in filas if _fuera_de_dup(f["ruta"]) and os.path.exists(f["ruta"])] + grupos, usados = [], set() + por_nombre = {} + for f in filas: + por_nombre.setdefault((f["nombre"].lower(), f["bytes"]), []).append(f) + for (nombre, b), fs in por_nombre.items(): + if len(fs) < 2: + continue + rutas = [f["ruta"] for f in fs] + queda = _mejor(rutas) + mueven = [r for r in rutas if r != queda] + usados.update(rutas) + grupos.append({"tipo": "identico", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven, + "bytes": b * len(mueven), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)}) + por_raiz = {} + for f in filas: + if f["ruta"] in usados or (f["ext"] or "").lower() not in _EXT_VIDEO_DUP: + continue + stem = os.path.splitext(f["nombre"])[0].lower() + if len(stem) < 6: + continue + por_raiz.setdefault(stem, []).append(f) + for stem, fs in por_raiz.items(): + if len({(f["ext"] or "").lower() for f in fs}) < 2: + continue + fs = sorted(fs, key=lambda f: -(f["bytes"] or 0)) + queda = fs[0]["ruta"] # el más pesado suele ser el original + mueven = [f["ruta"] for f in fs[1:]] + grupos.append({"tipo": "version", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven, + "bytes": sum(f["bytes"] or 0 for f in fs[1:]), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)}) + grupos.sort(key=lambda g: -g["bytes"]) + return {"grupos": grupos, "n": sum(len(g["mueven"]) for g in grupos), "bytes": sum(g["bytes"] for g in grupos), + "identicos": sum(1 for g in grupos if g["tipo"] == "identico"), "versiones": sum(1 for g in grupos if g["tipo"] == "version")} + + +def huella_rapida(ruta, trozo=1 << 20): + """sha256 del primer MB, uno del centro y el último (y del tamaño): suficiente para distinguir dos archivos que solo + coinciden en nombre y peso, sin leer gigas por la red.""" + h = hashlib.sha256() + tam = os.path.getsize(ruta) + h.update(str(tam).encode()) + with open(ruta, "rb") as fh: + h.update(fh.read(trozo)) + if tam > trozo * 3: + fh.seek(tam // 2) + h.update(fh.read(trozo)) + if tam > trozo * 2: + fh.seek(tam - trozo) + h.update(fh.read(trozo)) + return h.hexdigest() + + +def apartar_duplicados(raiz, grupos=None): + """Mueve cada copia a /Duplicados. Reversible por lote; nada se borra. + Las copias «idénticas» se comprueban por contenido antes de moverlas: si difieren, se dejan y se avisa.""" + raiz = os.path.normpath(raiz) + if grupos is None: + grupos = duplicados(raiz)["grupos"] + lote = "dup-" + time.strftime("%Y%m%d-%H%M%S") + movidos, errores, distintos = [], [], 0 + for g in grupos: + ref = None + if g.get("tipo") == "identico": + try: + ref = huella_rapida(g["queda"]) + except OSError as ex: + errores.append(f"{g['queda']}: no se pudo leer para comparar ({ex}); el grupo se deja") + continue + for r in g["mueven"]: + try: + if not os.path.exists(r): + continue + if ref is not None and huella_rapida(r) != ref: + distintos += 1 + errores.append(f"{os.path.relpath(r, raiz)}: mismo nombre y peso pero contenido distinto; se deja donde está") + continue + mover(r, os.path.join(g["destino"], os.path.basename(r)), lote, movidos) + quitar_vacias(os.path.dirname(r), raiz) + except Exception as ex: # noqa + errores.append(f"{r}: {ex}") + actualizar_inventario(movidos) + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(grupos), "movimientos": len(movidos), "errores": errores}) + return {"lote": lote, "grupos": len(grupos), "movimientos": len(movidos), "errores": errores, "distintos": distintos} + + +RESPALDO = {"estado": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "errores": [], "destino": "", "cancelar": False, "inicio": 0, "copiados": 0, "saltados": 0} + + +def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True): + con = db() + rc = raiz_cfg(raiz) + cliente = rc["cliente"] if rc else os.path.basename(raiz.rstrip("\\/")) or "Raiz" + base = os.path.join(destino, re.sub(r'[<>:"/\\|?*]', "_", cliente)) + conv = {} + for t in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('verificado','reemplazado')").fetchall(): + if os.path.exists(t["salida"]): + conv[os.path.normpath(t["ruta"]).lower()] = t["salida"] + filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0", (raiz,)).fetchall() + con.close() + marcas_p = [os.sep + p + os.sep for p in R.PAPELERAS] + marca_a = os.sep + "_Archivo" + os.sep + items, vistos = [], set() + omit_raw, omit_stock_b, omit_stock_n, convertidos = [], 0, 0, 0 + + def agregar(ruta, rol, ext, bytes_, mtime): + k = os.path.normpath(ruta).lower() + if k in vistos: + return + vistos.add(k) + rel_dir = os.path.relpath(os.path.dirname(ruta), raiz) + rel_dir = "" if rel_dir == "." else rel_dir + tipo = tipo_medio(rol, ext) + items.append({"origen": ruta, "rel_dir": rel_dir, "nombre": os.path.basename(ruta), "bytes": bytes_, "tipo": tipo, "mtime": mtime, "rel": os.path.relpath(ruta, raiz)}) + + for f in filas: + if any(m in f["ruta"] for m in marcas_p) or marca_a in f["ruta"]: + continue + if sin_stock and f["es_stock"]: + omit_stock_b += f["bytes"] + omit_stock_n += 1 + continue + k = os.path.normpath(f["ruta"]).lower() + if k in conv: + sal = conv[k] + try: + st = os.stat(sal) + except OSError: + continue + convertidos += 1 + vistos.add(k) + agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime) + continue + if f["rol"] == "camara_raw" and not incluir_raw: + omit_raw.append({"rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"]}) + continue + agregar(f["ruta"], f["rol"], f["ext"], f["bytes"], f["mtime"]) + for k, sal in conv.items(): + if os.path.normpath(sal).lower().startswith(os.path.normpath(raiz).lower() + os.sep) and not any(m in sal for m in marcas_p): + try: + st = os.stat(sal) + agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime) + except OSError: + pass + # la subcarpeta por tipo solo se añade donde una carpeta mezcla tipos de medio + # ("Audio/" ya es solo audio: no hace falta "Audio/Audio/") + tipos_por_dir = defaultdict(set) + for it in items: + tipos_por_dir[it["rel_dir"]].add(it["tipo"]) + for it in items: + mezcla = por_tipo and len(tipos_por_dir[it["rel_dir"]]) > 1 + partes = [base, it["rel_dir"], it["tipo"] if mezcla else "", it["nombre"]] + it["destino"] = os.path.normpath(os.path.join(*[p for p in partes if p])) + por_tipo_res = defaultdict(lambda: {"n": 0, "bytes": 0}) + for it in items: + por_tipo_res[it["tipo"]]["n"] += 1 + por_tipo_res[it["tipo"]]["bytes"] += it["bytes"] + omit_raw.sort(key=lambda x: -x["bytes"]) + return { + "cliente": cliente, "base": base, "n": len(items), "bytes": sum(i["bytes"] for i in items), + "por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])], + "convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]}, + "stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b}, + "muestra": [{"de": i["rel"], "a": os.path.relpath(i["destino"], destino)} for i in items[:25]], + "items": items, + } + + +def copiar_respaldo(raiz, plan): + RESPALDO.update(estado="corriendo", hechos=0, total=plan["n"], bytes=0, bytes_total=plan["bytes"], actual="", errores=[], + destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0) + indice = [] + for it in plan["items"]: + if RESPALDO["cancelar"]: + RESPALDO["estado"] = "cancelado" + break + d = it["destino"] + RESPALDO["actual"] = it["rel"] + try: + os.makedirs(os.path.dirname(d), exist_ok=True) + ya = False + if os.path.exists(d): + st = os.stat(d) + ya = st.st_size == it["bytes"] and abs(st.st_mtime - it["mtime"]) < 2 + if ya: + RESPALDO["saltados"] += 1 + else: + tmp = d + ".parcial" + shutil.copy2(it["origen"], tmp) + if os.path.getsize(tmp) != it["bytes"]: + raise OSError("tamaño distinto tras copiar") + os.replace(tmp, d) + RESPALDO["copiados"] += 1 + indice.append((os.path.relpath(d, plan["base"]), it["rel"], it["tipo"], it["bytes"], datetime.datetime.fromtimestamp(it["mtime"]).strftime("%Y-%m-%d"))) + except Exception as ex: # noqa + RESPALDO["errores"].append(f"{it['rel']}: {ex}") + RESPALDO["hechos"] += 1 + RESPALDO["bytes"] += it["bytes"] + try: + os.makedirs(plan["base"], exist_ok=True) + import csv + with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh: + w = csv.writer(fh, delimiter=";") + w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"]) + w.writerows(indice) + resumen = defaultdict(lambda: defaultdict(lambda: [0, 0])) + for rel_d, rel_o, tipo, b, _ in indice: + carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else "" + resumen[carpeta][tipo][0] += 1 + resumen[carpeta][tipo][1] += b + with open(os.path.join(plan["base"], "_MAPA.md"), "w", encoding="utf-8") as fh: + fh.write(f"# Respaldo de {plan['cliente']}\n\nOrigen: `{raiz}` \nFecha: {time.strftime('%Y-%m-%d %H:%M')} \n" + f"Archivos: {len(indice)} · {sum(i[3] for i in indice)/1e9:.1f} GB\n\n") + fh.write("Los clips de cámara están en MP4 H.265 10 bits (Main10). Los BRAW/originales pesados no se copian salvo que se pida.\n\n") + fh.write("| Carpeta | Tipo | Archivos | GB |\n|---|---|---:|---:|\n") + for carpeta in sorted(resumen): + for tipo, (n, b) in sorted(resumen[carpeta].items()): + fh.write(f"| {carpeta or '(raíz)'} | {tipo} | {n} | {b/1e9:.2f} |\n") + with open(os.path.join(plan["base"], "manifest.json"), "w", encoding="utf-8") as fh: + json.dump({"cliente": plan["cliente"], "origen": raiz, "fecha": time.time(), "archivos": len(indice), "bytes": sum(i[3] for i in indice), + "errores": RESPALDO["errores"]}, fh, ensure_ascii=False, indent=1) + except Exception as ex: # noqa + RESPALDO["errores"].append(f"índice: {ex}") + if RESPALDO["estado"] == "corriendo": + RESPALDO["estado"] = "listo" + bitacora({"lote": "respaldo", "op": "respaldo", "raiz": raiz, "a": plan["base"], "archivos": RESPALDO["hechos"], "copiados": RESPALDO["copiados"], + "saltados": RESPALDO["saltados"], "bytes": RESPALDO["bytes"], "errores": len(RESPALDO["errores"])}) + + +# ========================================================================== +# Servidor HTTP +# ========================================================================== + +PLANES = {} # raiz -> plan +PLANES_RESPALDO = {} + + +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).""" + k = _clave(ruta) + r = raiz_cfg(ruta) + if not r: + raise ValueError("esa carpeta no está registrada") + if (PROGRESO.get(k) or {}).get("estado") == "corriendo" or (ANALISIS.get(k) or {}).get("estado") == "corriendo": + 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") + con = db() + activos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo')", (k.lower(),)).fetchone()["n"] + 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") + 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) + if borrar_datos: + for t in ("archivos", "carpetas", "escaneos"): + con.execute(f"DELETE FROM {t} WHERE lower(raiz) = ?", (k.lower(),)) + con.commit() + con.close() + bitacora({"op": "raiz_quitada", "raiz": k, "datos_borrados": bool(borrar_datos)}) + return {"ok": True, "raices": len(CONFIG["raices"])} + + +def estado_general(): + """Todo lo que la interfaz sondea cada pocos segundos, en una sola respuesta.""" + con = db() + esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()} + cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()} + con.close() + esc_ci = {k.lower(): v for k, v in esc.items()} + raices = [] + for r in CONFIG["raices"]: + k = _clave(r["ruta"]) + raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)}) + return {"config": CONFIG, "raices": raices, "herramientas": herramientas(), + "version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO, + "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "n": cola_n}, + "aplicando": APLICANDO, "asistente": asistente.ESTADO, + "respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])}, + "cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "plantillas": PLANTILLAS} + + +def estado_carpeta(ruta): + """El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea.""" + raiz = _clave(ruta) + r = raiz_cfg(raiz) + if not r: + return {"error": "carpeta no registrada"} + con = db() + esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() + trabajos = {x["estado"]: {"n": x["n"], "bytes_in": x["bi"] or 0, "bytes_out": x["bo"] or 0} + for x in con.execute("SELECT estado, COUNT(*) n, SUM(bytes_in) bi, SUM(bytes_out) bo FROM trabajos WHERE raiz = ? GROUP BY estado", (raiz,)).fetchall()} + analizados = con.execute("SELECT SUM(analizado = 1) ok, SUM(analizado = 0) sin FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','video')", (raiz,)).fetchone() + con.close() + prog = PROGRESO.get(raiz) + diag = asistente.diagnostico(raiz) if esc else None + g = {x["clave"]: x for x in (diag["grupos"] if diag else [])} + # último lote aplicado sobre esta carpeta + lotes = [] + for e in leer_bitacora(3000): + if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower(): + lotes.append({"lote": e.get("lote"), "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])}) + lotes.sort(key=lambda x: -(x["ts"] or 0)) + plan = PLANES.get(raiz) + plan_res = None + if plan: + filas = plan["filas"] + plan_res = {"filas": len(filas), "marcadas": sum(1 for f in filas if f.get("marcado")), "bytes": sum(f["bytes"] for f in filas), + "alta": sum(1 for f in filas if f["confianza"] == "alta"), "baja": sum(1 for f in filas if f["confianza"] == "baja"), + "avisos": plan.get("avisos") or []} + sin_nombre = [t for t in vocabulario(raiz) if not t.get("cubeta") and not t.get("dentro_pieza")] if esc else [] + cam_bytes = (g.get("camara_raw", {}).get("bytes", 0) + g.get("camara_video", {}).get("bytes", 0)) + resp_plan = PLANES_RESPALDO.get(raiz) + return { + "raiz": raiz, "cliente": r["cliente"], "tipo": r["tipo"], + "escaneo": dict(esc) if esc else None, "progreso": prog, + "diagnostico": diag, + "sin_nombre": [{"token": t["token"], "ejemplo": t["ejemplo"], "n": t["n"], "bytes": t["bytes"]} for t in sin_nombre[:12]], + "sin_nombre_n": len(sin_nombre), + "plan": plan_res, "lotes": lotes[:6], + "convertir": {"camara_bytes": cam_bytes, "raw_bytes": g.get("camara_raw", {}).get("bytes", 0), "trabajos": trabajos, + "analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0, + "cola_activa": COLA["activa"]}, + "respaldo": {"destino": CONFIG.get("respaldo", {}).get("destino", ""), "plan": ({"n": resp_plan["n"], "bytes": resp_plan["bytes"], "base": resp_plan.get("base")} if resp_plan else None), + "estado": RESPALDO.get("estado"), "raw_pendientes": (resp_plan or {}).get("raw_omitidos", {}).get("n", 0)}, + "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None, + } + + +class Manejador(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + pass + + def _json(self, datos, codigo=200): + cuerpo = json.dumps(datos, ensure_ascii=False, default=str).encode("utf-8") + self.send_response(codigo) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(cuerpo))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(cuerpo) + + def _cuerpo(self): + n = int(self.headers.get("Content-Length") or 0) + if not n: + return {} + try: + return json.loads(self.rfile.read(n).decode("utf-8")) + except ValueError: + return {} + + def _es_local(self): + """Solo acepta peticiones que vienen de la propia página (evita que otra web abierta en el navegador dé órdenes).""" + host = (self.headers.get("Host") or "").lower() + if host not in (f"127.0.0.1:{PUERTO}", f"localhost:{PUERTO}"): + return False + origen = self.headers.get("Origin") + if origen and origen.lower() not in (f"http://127.0.0.1:{PUERTO}", f"http://localhost:{PUERTO}"): + return False + return True + + def do_GET(self): + u = urlparse(self.path) + q = {k: v[0] for k, v in parse_qs(u.query).items()} + ruta = u.path + try: + if ruta in ("/", "/index.html"): + with open(os.path.join(BASE, "static", "index.html"), "rb") as f: + cuerpo = f.read() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(cuerpo))) + self.end_headers() + self.wfile.write(cuerpo) + return + if ruta.startswith("/static/") and re.fullmatch(r"/static/[A-Za-z0-9_.-]+", ruta): + p = os.path.join(BASE, "static", ruta[len("/static/"):]) + if not os.path.isfile(p): + return self._json({"error": "no existe"}, 404) + tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css", ".js": "text/javascript"} + with open(p, "rb") as f: + cuerpo = f.read() + self.send_response(200) + self.send_header("Content-Type", tipos.get(os.path.splitext(p)[1].lower(), "application/octet-stream")) + self.send_header("Content-Length", str(len(cuerpo))) + self.send_header("Cache-Control", "max-age=86400") + self.end_headers() + self.wfile.write(cuerpo) + return + if ruta == "/api/estado": + return self._json(estado_general()) + if ruta == "/api/carpeta/estado": + return self._json(estado_carpeta(q["raiz"])) + if ruta == "/api/mapa": + return self._json(mapa(q["raiz"])) + if ruta == "/api/actualizacion": + return self._json({"version": VERSION, **actualizador.ESTADO}) + if ruta == "/api/diagnostico": + return self._json(asistente.diagnostico(q["raiz"])) + if ruta == "/api/asistente/estado": + return self._json(asistente.ESTADO) + if ruta == "/api/asistente/pasos": + raiz = os.path.normpath(q["raiz"]) + r = raiz_cfg(raiz) + if not r: + return self._json({"error": "raíz no registrada"}, 400) + d = asistente.diagnostico(raiz) + filas = Plan(r).correr()["filas"] + asistente.PLAN_CACHE[raiz] = filas + return self._json({"diagnostico": d, "pasos": asistente.pasos(d, filas), "movimientos": len(filas)}) + if ruta == "/api/discos": + return self._json(discos()) + if ruta == "/api/vocabulario": + return self._json({"tokens": vocabulario(os.path.normpath(q["raiz"])), "diccionario": DICC}) + if ruta == "/api/diccionario": + return self._json(DICC) + if ruta == "/api/plan": + r = raiz_cfg(q["raiz"]) + if not r: + return self._json({"error": "raíz no configurada"}, 400) + plan = Plan(r).correr() + PLANES[plan["raiz"]] = plan + return self._json(plan) + if ruta == "/api/bitacora": + ent = leer_bitacora(3000) + lotes = {} + for e in ent: + l = e.get("lote") + if not l: + continue + lo = lotes.setdefault(l, {"lote": l, "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []}) + if e.get("op") == "mover": + lo["movimientos"] += 1 + if len(lo["ejemplos"]) < 5: + lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")}) + if e.get("op") == "fin": + lo["raiz"] = e.get("raiz", "") + lo["errores"] = len(e.get("errores") or []) + if e.get("op") == "deshacer": + lotes.setdefault(e.get("de_lote"), {"lote": e.get("de_lote"), "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})["deshecho"] = True + return self._json({"lotes": sorted([v for k, v in lotes.items() if not k.endswith("-deshecho")], key=lambda x: -(x["ts"] or 0))}) + if ruta == "/api/optimizar/candidatos": + o = CONFIG["optimizar"] + return self._json({"candidatos": candidatos(os.path.normpath(q["raiz"]), q.get("meses", o["meses_minimos"]), q.get("perfil", o["perfil"]), + q.get("min_mb", o["min_mb"]), q.get("exportados") == "1")}) + if ruta == "/api/optimizar/cola": + con = db() + filas = [dict(r) for r in con.execute("SELECT * FROM trabajos ORDER BY id DESC LIMIT 500").fetchall()] + con.close() + return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"]}}) + if ruta == "/api/stock": + tipos = [t for t in (q.get("tipos") or "Video").split(",") if t] + return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos)) + if ruta == "/api/papelera": + return self._json(papelera(os.path.normpath(q["raiz"]))) + if ruta == "/api/duplicados": + return self._json(duplicados(os.path.normpath(q["raiz"]))) + if ruta == "/api/respaldo/plan": + raiz = os.path.normpath(q["raiz"]) + destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "") + if not destino: + return self._json({"error": "indica la carpeta de destino del respaldo"}, 400) + plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1") + PLANES_RESPALDO[raiz] = plan + CONFIG.setdefault("respaldo", {})["destino"] = destino + guardar_json(CONFIG_PATH, CONFIG) + return self._json({k: v for k, v in plan.items() if k != "items"}) + if ruta == "/api/respaldo/estado": + return self._json(RESPALDO) + if ruta == "/api/buscar-carpeta": + return self._json({"coincidencias": buscar_carpeta(q.get("nombre", ""))}) + return self._json({"error": "no existe"}, 404) + except Exception as ex: # noqa + import traceback + return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500) + + def do_POST(self): + u = urlparse(self.path) + ruta = u.path + if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"): + return self._json({"error": "petición no permitida"}, 403) + d = self._cuerpo() + try: + if ruta == "/api/escanear": + return self._json({"lanzado": lanzar_escaneo(d["raiz"])}) + if ruta == "/api/raiz/quitar": + try: + return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")))) + except ValueError as e: + return self._json({"error": str(e)}, 409) + if ruta == "/api/raices": + CONFIG["raices"] = d["raices"] + guardar_json(CONFIG_PATH, CONFIG) + return self._json({"ok": True}) + if ruta == "/api/clientes": + CONFIG["clientes"] = d["clientes"] + guardar_json(CONFIG_PATH, CONFIG) + _DISCOS_CACHE["ts"] = 0 + return self._json({"ok": True}) + if ruta == "/api/config": + # solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json + permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}} + for k, v in d.items(): + if k in permitidas and isinstance(v, dict): + CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]}) + guardar_json(CONFIG_PATH, CONFIG) + return self._json({"ok": True, "config": CONFIG}) + if ruta == "/api/diccionario": + tok = R.norm(d.get("token", "")) + cub = d.get("cubeta") + if not tok: + return self._json({"error": "token vacío"}, 400) + with LOCK: + if cub in (None, ""): + DICC["carpetas"].pop(tok, None) + else: + DICC["carpetas"][tok] = cub + DICC["aprendido"].append({"ts": time.time(), "token": tok, "cubeta": cub}) + guardar_json(DICC_PATH, DICC) + return self._json({"ok": True}) + if ruta == "/api/regla": + with LOCK: + DICC["reglas"] = [r for r in DICC["reglas"] if not (R.norm(r.get("origen", "")) == R.norm(d.get("origen", "")) and r.get("cliente") == d.get("cliente"))] + if d.get("destino") is not None: + DICC["reglas"].append({"cliente": d.get("cliente"), "origen": d.get("origen"), "destino": d.get("destino"), + "cubeta": d.get("cubeta", "REVISAR"), "ts": time.time()}) + guardar_json(DICC_PATH, DICC) + return self._json({"ok": True, "reglas": DICC["reglas"]}) + if ruta == "/api/plantillas": + PLANTILLAS.update(d.get("plantillas", {})) + guardar_json(PLANTILLAS_PATH, PLANTILLAS) + return self._json({"ok": True}) + if ruta == "/api/aplicar": + raiz = os.path.normpath(d["raiz"]) + plan = PLANES.get(raiz) + if not plan: + return self._json({"error": "primero genera el plan"}, 400) + ids = set(d.get("ids", [])) + filas = [f for f in plan["filas"] if f["id"] in ids] + # respetar destinos editados en la interfaz + for f in filas: + ed = (d.get("destinos") or {}).get(str(f["id"])) + if ed: + nuevo = os.path.normpath(os.path.join(raiz, ed)) + if not dentro_de(raiz, nuevo): + return self._json({"error": f"el destino «{ed}» queda fuera de la carpeta del cliente"}, 400) + f["destino"] = nuevo + res = aplicar(raiz, filas) + PLANES.pop(raiz, None) + return self._json(res) + if ruta == "/api/deshacer": + return self._json(deshacer(d["lote"])) + if ruta == "/api/actualizacion/buscar": + actualizador.comprobar() + return self._json({"version": VERSION, **actualizador.ESTADO}) + if ruta == "/api/actualizacion/instalar": + ok = actualizador.descargar_y_aplicar(en_hilo=True) + return self._json({"lanzado": bool(ok), **actualizador.ESTADO}) + if ruta == "/api/tour-visto": + CONFIG["tour_visto"] = True + guardar_json(CONFIG_PATH, CONFIG) + return self._json({"ok": True}) + if ruta == "/api/asistente": + raiz = os.path.normpath(d["raiz"]) + if asistente.ESTADO["activo"]: + return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"}) + asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True) + return self._json({"lanzado": True}) + if ruta == "/api/optimizar/analizar": + raiz = os.path.normpath(d["raiz"]) + p = ANALISIS.get(raiz) + if p and p["estado"] == "corriendo": + return self._json({"lanzado": False}) + threading.Thread(target=analizar, args=(raiz, d.get("min_mb", CONFIG["optimizar"]["min_mb"])), daemon=True).start() + return self._json({"lanzado": True}) + if ruta == "/api/optimizar/encolar": + o = CONFIG["optimizar"] + ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"])) + if d.get("iniciar"): + iniciar_cola() + return self._json({"ids": ids}) + if ruta == "/api/optimizar/control": + acc = d.get("accion") + if acc == "iniciar": + return self._json({"ok": iniciar_cola()}) + if acc == "detener": + COLA["cancelar"] = True + COLA["activa"] = False + return self._json({"ok": True}) + if acc == "quitar": + con = db() + with con: + con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", [])) + con.close() + return self._json({"ok": True}) + if acc == "reintentar": + 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", [])) + con.close() + return self._json({"ok": True}) + if ruta == "/api/optimizar/verificar": + return self._json({"resultados": verificar(d.get("ids", []))}) + if ruta == "/api/optimizar/reemplazar": + return self._json({"resultados": reemplazar(d.get("ids", []))}) + if ruta == "/api/stock/papelera": + return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", []))) + if ruta == "/api/duplicados/apartar": + return self._json(apartar_duplicados(os.path.normpath(d["raiz"]))) + if ruta == "/api/abrir": + return self._json(abrir_carpeta(d.get("ruta", ""))) + if ruta == "/api/respaldo/copiar": + raiz = os.path.normpath(d["raiz"]) + plan = PLANES_RESPALDO.get(raiz) + if not plan: + return self._json({"error": "primero calcula el respaldo"}, 400) + if RESPALDO["estado"] == "corriendo": + return self._json({"error": "ya hay una copia en curso"}, 400) + threading.Thread(target=copiar_respaldo, args=(raiz, plan), daemon=True).start() + return self._json({"ok": True}) + if ruta == "/api/respaldo/detener": + RESPALDO["cancelar"] = True + return self._json({"ok": True}) + return self._json({"error": "no existe"}, 404) + except Exception as ex: # noqa + import traceback + return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500) + + +# ========================================================================== +# Línea de comandos: organizar una carpeta con un solo comando +# ========================================================================== + +AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando + + OrganizadorLabstream.exe abre la interfaz en el navegador + OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra + de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer + OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar) + OrganizadorLabstream.exe organizar "ruta" [opciones] + --cliente "Nombre" nombre del cliente (por defecto, el de la carpeta) + --tipo contenido|pauta|proyecto|interno (por defecto contenido) + --solo-alta aplica solo los movimientos de confianza alta + --todo aplica también los de confianza baja y los cosméticos + --simular solo muestra el plan, no mueve nada + --si no pregunta: aplica directamente + OrganizadorLabstream.exe escanear "ruta" solo inventaría (para ver el mapa en la interfaz) + OrganizadorLabstream.exe stock "ruta" [--meses 3] [--todo-tipo] [--si] aparta el stock de Envato viejo a _Para borrar/stock + OrganizadorLabstream.exe respaldo "ruta" "destino" [--con-raw] [--con-stock] copia compacta a otro disco + OrganizadorLabstream.exe deshacer LOTE deshace un lote de la bitácora + OrganizadorLabstream.exe actualizar [--si] busca versión nueva en Labstream OS y la instala + OrganizadorLabstream.exe duplicados "ruta" [--si] lista los duplicados; con --si los junta en subcarpetas Duplicados + +La app NUNCA borra: lo que sobra va a _Para borrar dentro de la misma carpeta (la borra una persona a mano) y todo queda en datos\\bitacora.jsonl. +""" + + +def _gb(b): + return f"{(b or 0) / 1e9:.1f} GB" if (b or 0) >= 1e8 else f"{(b or 0) / 1e6:.0f} MB" + + +def _opcion(args, nombre, defecto=None): + if nombre in args: + i = args.index(nombre) + return args[i + 1] if i + 1 < len(args) else defecto + return defecto + + +def _confirmar(pregunta, auto): + if auto: + return True + try: + return input(pregunta + " [S/n] ").strip().lower() in ("", "s", "si", "sí", "y") + except EOFError: + return False + + +def cli_escanear(ruta): + ruta = os.path.normpath(ruta) + print(f"Escaneando {ruta} …", flush=True) + escanear(ruta) + p = PROGRESO[ruta] + if p["estado"] != "listo": + print("ERROR al escanear:", p.get("error")) + return False + print(f" {p['n']:,} archivos · {_gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s") + return True + + +def cli_raiz(ruta, args): + """asegura que la carpeta esté registrada como raíz (la crea si es nueva)""" + ruta = os.path.normpath(ruta) + if not os.path.isdir(ruta): + print("No existe la carpeta:", ruta) + return None + r = raiz_cfg(ruta) + if not r: + nombre = os.path.basename(ruta.rstrip("\\/")) or ruta + cliente = _opcion(args, "--cliente") or next((c for c, i in CONFIG["clientes"].items() + if any(R.norm(a) == R.norm(nombre) for a in i.get("alias", []) + [c])), nombre) + tipo = _opcion(args, "--tipo") or CONFIG["clientes"].get(cliente, {}).get("tipo", "contenido") + r = {"ruta": ruta, "cliente": cliente, "tipo": tipo} + CONFIG["raices"].append(r) + CONFIG["clientes"].setdefault(cliente, {"tipo": tipo, "alias": [nombre]}) + guardar_json(CONFIG_PATH, CONFIG) + print(f"Carpeta nueva registrada como raíz: cliente «{cliente}», tipo {tipo}") + return r + + +def cli_organizar(ruta, args): + r = cli_raiz(ruta, args) + if not r or not cli_escanear(r["ruta"]): + return 1 + plan = Plan(r).correr() + filas = plan["filas"] + if not filas: + print("Nada que mover: la carpeta ya está en orden.") + return 0 + if "--todo" in args: + elegidas = filas + elif "--solo-alta" in args: + elegidas = [f for f in filas if f["confianza"] == "alta"] + else: + elegidas = [f for f in filas if f["marcado"]] + print(f"\nPLAN: {len(filas)} movimientos propuestos; se aplicarían {len(elegidas)} " + f"({_gb(sum(f['bytes'] for f in elegidas))}, {sum(f['n'] for f in elegidas):,} archivos)\n") + for f in sorted(elegidas, key=lambda f: -f["bytes"])[:60]: + n = f" [{len(f['archivos'])} archivos]" if f["archivos"] else "" + print(f" {f['confianza']:5} {f['cubeta']:10} {_gb(f['bytes']):>8} {f['origen_rel']}{n}\n{'':28}-> {f['destino_rel']} ({f['motivo'][:70]})") + if len(elegidas) > 60: + print(f" … y {len(elegidas) - 60} más") + omitidas = [f for f in filas if f not in elegidas] + if omitidas: + print(f"\n Sin aplicar (confianza baja o cosméticos): {len(omitidas)}. Con --todo se aplican; con la interfaz se revisan uno a uno.") + for a in plan["avisos"]: + print(" AVISO:", a) + if "--simular" in args: + print("\nSimulación: no se movió nada.") + return 0 + if not elegidas: + return 0 + if not _confirmar("\n¿Aplicar estos movimientos? (los archivos no se renombran; lo sobrante va a _Para borrar)", "--si" in args): + print("Cancelado. No se movió nada.") + return 0 + res = aplicar(r["ruta"], elegidas) + print(f"\nHecho: lote {res['lote']} · {res['movimientos']} movimientos · {len(res['errores'])} errores") + for e in res["errores"][:20]: + print(" ERROR:", e) + print(f"Para deshacer: OrganizadorLabstream.exe deshacer {res['lote']}") + return 0 if not res["errores"] else 2 + + +def cli_stock(ruta, args): + r = cli_raiz(ruta, args) + if not r or not cli_escanear(r["ruta"]): + return 1 + meses = int(_opcion(args, "--meses", CONFIG["optimizar"]["meses_minimos"])) + tipos = ["Video", "Audio", "Imagenes"] if "--todo-tipo" in args else ["Video"] + s = stock_viejo(r["ruta"], meses, tipos) + print(f"\nStock de Envato en proyectos anteriores a {s['tope']}: {s['n']} archivos · {_gb(s['bytes'])}") + for g in s["grupos"][:40]: + print(f" {_gb(g['bytes']):>8} {g['carpeta']} ({g['n']}: {', '.join(g['ejemplos'])[:80]})") + if not s["n"] or "--simular" in args: + return 0 + if not _confirmar("¿Apartar todo a _Para borrar/stock? (reversible)", "--si" in args): + return 0 + res = stock_a_papelera(r["ruta"], [x for g in s["grupos"] for x in g["rutas"]]) + print(f"Hecho: lote {res['lote']} · {res['movimientos']} archivos apartados. Están en _Para borrar\\stock: revísalos y bórralos a mano.") + return 0 + + +def cli_respaldo(ruta, destino, args): + r = cli_raiz(ruta, args) + if not r or not cli_escanear(r["ruta"]): + return 1 + plan = plan_respaldo(r["ruta"], destino, True, "--con-raw" in args, "--con-stock" not in args) + print(f"\nRespaldo de «{plan['cliente']}» → {plan['base']}: {plan['n']:,} archivos · {_gb(plan['bytes'])}") + print(" por tipo:", ", ".join(f"{t['tipo']} {t['n']} ({_gb(t['bytes'])})" for t in plan["por_tipo"])) + if plan["raw_omitidos"]["n"]: + print(f" NO se copian {plan['raw_omitidos']['n']} originales de cámara sin convertir ({_gb(plan['raw_omitidos']['bytes'])}); conviértelos antes o usa --con-raw") + if "--simular" in args or not _confirmar("¿Copiar?", "--si" in args): + return 0 + hilo = threading.Thread(target=copiar_respaldo, args=(r["ruta"], plan), daemon=True) + hilo.start() + ultimo = -1 + while hilo.is_alive(): + time.sleep(2) + pct = int(RESPALDO["bytes"] / RESPALDO["bytes_total"] * 100) if RESPALDO["bytes_total"] else 0 + if pct != ultimo: + print(f" {pct:3d}% {RESPALDO['hechos']}/{RESPALDO['total']} {RESPALDO['actual'][-60:]}", flush=True) + ultimo = pct + print(f"\n{RESPALDO['estado']}: {RESPALDO['copiados']} copiados, {RESPALDO['saltados']} ya estaban, {len(RESPALDO['errores'])} errores. Índice en {plan['base']}\\INDICE.csv") + return 0 + + +def cli(args): + cmd = args[0].lower() if args else "" + resto = args[1:] + if cmd in ("-h", "--help", "ayuda", "/?"): + print(AYUDA) + return 0 + if os.path.isdir(args[0]) and cmd not in ("organizar", "escanear", "stock", "respaldo", "deshacer", "duplicados", "asistente"): + return asistente.cli(args[0], resto) # arrastraron una carpeta encima del .exe + if cmd == "asistente" and resto: + return asistente.cli(resto[0], resto[1:]) + if cmd in ("actualizar", "actualizacion"): + return actualizador.cli(resto) + if cmd == "organizar" and resto: + return cli_organizar(resto[0], resto[1:]) + if cmd == "escanear" and resto: + r = cli_raiz(resto[0], resto[1:]) + return 0 if r and cli_escanear(r["ruta"]) else 1 + if cmd == "stock" and resto: + return cli_stock(resto[0], resto[1:]) + if cmd == "respaldo" and len(resto) >= 2: + return cli_respaldo(resto[0], resto[1], resto[2:]) + if cmd == "deshacer" and resto: + res = deshacer(resto[0]) + print(f"Deshechos {res['deshechos']} movimientos · {len(res['errores'])} errores") + return 0 + if cmd == "duplicados" and resto: + d = duplicados(os.path.normpath(resto[0])) + print(f"{len(d['grupos'])} grupos · {d['n']} copias · {d['bytes'] / 1e9:.1f} GB ({d['identicos']} idénticos, {d['versiones']} versiones MP4/MOV)") + for g in d["grupos"][:40]: + print(f" [{g['tipo']}] {g['nombre']} queda: {g['queda']} → {len(g['mueven'])} a {g['destino']}") + if "--si" in resto and d["grupos"]: + print(apartar_duplicados(os.path.normpath(resto[0]), d["grupos"])) + return 0 + print(AYUDA) + return 1 + + +def _asegurar_consola(): + """El .exe es de ventana (sin consola). Para los comandos y el arrastrar-carpeta hace falta una: + se engancha a la del padre (cmd/PowerShell) o se abre una nueva.""" + if not CONGELADO or os.name != "nt": + return + try: + import ctypes + k = ctypes.windll.kernel32 + if k.GetConsoleWindow(): # ya hay consola + return + h = k.GetStdHandle(-11) + if h and h != -1 and k.GetFileType(h) in (1, 3): # salida a archivo o tubería: se respeta + return + if not k.AttachConsole(-1): + k.AllocConsole() + sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace", buffering=1) + sys.stderr = sys.stdout + sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace") + k.SetConsoleTitleW("OrganiLAB") + except Exception: # noqa + pass + + +def _ventana(url): + """Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador.""" + try: + import webview + except Exception: # noqa + webbrowser.open(url) + return False + try: + v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff") + webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana")) + return True + except Exception as ex: # noqa + print("Sin WebView2; abro el navegador:", ex) + webbrowser.open(url) + return False + + +def main(): + args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")] + if args_tmp and "--puente" not in sys.argv: + _asegurar_consola() + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: # noqa + pass + if "--puente" in sys.argv: # modo puente con Resolve (lo usa el propio .exe) + import resolve_bridge + sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1])) + args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")] + if args: + if CONGELADO and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?"): + actualizador.comprobar_en_segundo_plano() + codigo = cli(args) + d = actualizador.ESTADO.get("disponible") + if d: + print(f"\nHay una versión nueva del Organizador ({d['version']}). Para instalarla: OrganizadorLabstream.exe actualizar") + if CONGELADO and "--si" not in args and sys.stdin and sys.stdin.isatty(): + try: + input("\nPulsa Enter para cerrar.") + except EOFError: + pass + sys.exit(codigo) + 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)") + try: + srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador) + except OSError: + print(f"Ya hay un Organizador abierto en el puerto {PUERTO}; abro esa ventana.") + webbrowser.open(f"http://127.0.0.1:{PUERTO}/") + return + url = f"http://127.0.0.1:{PUERTO}/" + if sys.stdout: + print(f"OrganiLAB {VERSION} en {url} (Ctrl+C para cerrar)") + if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto", True): + actualizador.comprobar_en_segundo_plano() + if "--sin-navegador" in sys.argv: # solo el servidor (desarrollo / vista previa) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + return + hilo = threading.Thread(target=srv.serve_forever, daemon=True) + hilo.start() + if "--navegador" in sys.argv: + webbrowser.open(url) + try: + hilo.join() + except KeyboardInterrupt: + pass + return + # ventana propia: al cerrarla se cierra el programa (salvo que haya trabajo en marcha) + _ventana(url) + while COLA["activa"] or RESPALDO.get("estado") == "corriendo" or asistente.ESTADO.get("activo"): + time.sleep(1) + srv.shutdown() + + +if __name__ == "__main__": + main() diff --git a/publicar.sh b/publicar.sh new file mode 100644 index 0000000..fdfa3d9 --- /dev/null +++ b/publicar.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Publica la versión ya empaquetada (empaquetar.py) para que las copias instaladas se actualicen solas: +# 1. sube los dos zips al NAS (storage/herramientas/ del OS) +# 2. deja el manifiesto en GitHub (labstream-os/public/herramientas/organizador.json), en un commit +# que solo toca ese archivo, sin mezclar cambios locales de labstream-os +# 3. lanza el despliegue del OS en el NAS y espera a que publique la versión +# Uso (desde Git Bash): bash publicar.sh +# Requisitos: llave ~/.ssh/editorias_nas con acceso Labstream@192.168.0.22 (sudo sin contraseña), +# y permiso de push a Labstream2026/labstream-os. +set -e +AQUI="$(cd "$(dirname "$0")" && pwd)" +OS="$AQUI/../labstream-os" +NAS="Labstream@192.168.0.22" +SSH="ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -i $HOME/.ssh/editorias_nas" +DEST=/volume5/contenedores/labstream-os/data/storage/herramientas +V=$(grep -o '"version": "[^"]*"' "$AQUI/manifiesto.json" | head -1 | cut -d'"' -f4) +APP="OrganizadorLabstream-app-$V.zip"; FULL="OrganizadorLabstream-portatil-$V.zip" +[ -f "$AQUI/$APP" ] && [ -f "$AQUI/$FULL" ] || { echo "Faltan los zips de la $V: corre antes py -3.12 empaquetar.py"; exit 1; } +echo "== OrganiLAB $V ==" + +echo "1/3 subiendo al NAS…" +# el NAS no tiene SFTP: scp en modo antiguo (-O). La carpeta es del usuario del contenedor (1001): se sube a +# la carpeta personal y se mueve con sudo. +scp -O -q -o BatchMode=yes -o StrictHostKeyChecking=no -i "$HOME/.ssh/editorias_nas" "$AQUI/$APP" "$AQUI/$FULL" "$NAS:~/" +$SSH "$NAS" "sudo mv ~/$APP ~/$FULL $DEST/ && sudo chown 1001:1001 $DEST/$APP $DEST/$FULL && cd $DEST && sha256sum $APP $FULL" 2>/dev/null +grep -o '"sha256": "[^"]*"' "$AQUI/manifiesto.json" | sed 's/.*: "//; s/"//' | sed 's/^/local /' + +echo "2/3 manifiesto a GitHub…" +cd "$OS" +git fetch -q origin main +export GIT_INDEX_FILE="$(mktemp)" +git read-tree origin/main +blob=$(git hash-object -w public/herramientas/organizador.json) +git update-index --add --cacheinfo "100644,$blob,public/herramientas/organizador.json" +tree=$(git write-tree); unset GIT_INDEX_FILE +if [ "$tree" = "$(git rev-parse origin/main^{tree})" ]; then echo " (el manifiesto ya estaba publicado)"; else + commit=$(git commit-tree "$tree" -p origin/main -m "OrganiLAB $V: paquete y manifiesto") + git push -q origin "$commit:main" && echo " commit $(echo $commit | cut -c1-8) en main" +fi + +echo "3/3 desplegando el OS (tarda unos minutos)…" +# por SSH, sudo no lleva /usr/local/bin en el PATH y «docker» no se encuentra: se pasa a mano +$SSH "$NAS" "sudo env PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin bash /var/services/homes/Labstream/claude-job.sh >/dev/null 2>&1; tail -1 /volume5/despliegues/labstream-os-deploy.log" 2>/dev/null +for i in $(seq 1 30); do + pub=$(curl -s -H "Cache-Control: no-cache" https://os.labstreamsas.com/herramientas/organizador.json | grep -o '"version": "[^"]*"' | cut -d'"' -f4) + [ "$pub" = "$V" ] && { echo "Publicado: os.labstreamsas.com sirve la $V. Las copias instaladas avisarán al abrirse."; exit 0; } + sleep 10 +done +echo "El OS aún sirve la $pub. Mira /volume5/despliegues/labstream-os-deploy.log en el NAS."; exit 1 diff --git a/reglas.py b/reglas.py new file mode 100644 index 0000000..7cb9ab8 --- /dev/null +++ b/reglas.py @@ -0,0 +1,559 @@ +# -*- coding: utf-8 -*- +""" +Reglas de clasificación del material de Labstream. + +Aquí vive todo lo que "sabe" el organizador: cómo reconocer de qué cámara +viene un archivo, qué es una carpeta por su nombre, qué es basura y la +estructura canónica a la que se lleva cada cosa según el tipo de cliente. + +El diccionario de carpetas se siembra desde aquí la primera vez y después +se guarda en datos/diccionario.json, donde la app va aprendiendo. +""" +import re +import unicodedata +import datetime + +# -------------------------------------------------------------------------- +# Normalización de nombres +# -------------------------------------------------------------------------- + +def norm(s: str) -> str: + """minúsculas, sin acentos, separadores colapsados a un espacio""" + s = unicodedata.normalize("NFKD", s or "") + s = "".join(c for c in s if not unicodedata.combining(c)) + s = s.lower() + s = re.sub(r"[\s_\-\.:,;()\[\]{}]+", " ", s) + return s.strip() + + +MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", + "agosto", "septiembre", "octubre", "noviembre", "diciembre"] +MESES_TITULO = [m.capitalize() for m in MESES] +MESES_ALIAS = { + "setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre", + "sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo", + "abr": "abril", "may": "mayo", "jun": "junio", "jul": "julio", "ago": "agosto", + "oct": "octubre", "nov": "noviembre", "dic": "diciembre", + "january": "enero", "february": "febrero", "march": "marzo", "april": "abril", + "june": "junio", "july": "julio", "august": "agosto", "september": "septiembre", + "october": "octubre", "november": "noviembre", "december": "diciembre", +} + + +def detectar_anio(nombre: str): + m = re.fullmatch(r"\s*(20\d\d)\s*", nombre or "") + return int(m.group(1)) if m else None + + +def detectar_mes(nombre: str): + """Devuelve (num_mes, conflicto) si la carpeta ES un mes ("09- Septiembre", + "02-marzo", "MAYO", "05"). conflicto=True cuando el número no coincide con + el nombre (gana el nombre).""" + n = norm(nombre) + m = re.fullmatch(r"(\d{1,2})?\s*([a-z]*)", n) + if not m: + return None, False + num = int(m.group(1)) if m.group(1) else None + palabra = m.group(2) + palabra = MESES_ALIAS.get(palabra, palabra) + mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None + if mes_por_nombre: + return mes_por_nombre, (num is not None and num != mes_por_nombre) + if num and 1 <= num <= 12 and not palabra: + return num, True # «3» solo puede ser marzo… o la pieza número 3: se propone con confianza media, no alta + return None, False + + +def nombre_mes(mes: int) -> str: + """convención de la casa: «MM- Mes» (la forma más usada en las carpetas actuales)""" + return f"{mes:02d}- {MESES_TITULO[mes - 1]}" + + +def detectar_pieza(nombre: str): + """'01- Exosomas' -> (1, 'Exosomas'); None si no está numerada.""" + if detectar_anio(nombre) or detectar_mes(nombre)[0]: + return None + m = re.match(r"^\s*(\d{1,2})\s*[-_.:]+\s*(.+?)\s*$", nombre or "") + if not m: + return None + num, titulo = int(m.group(1)), m.group(2) + if num == 0: + return None # "00_Grabacion" es estructura, no una pieza + titulo = re.sub(r"\s{2,}", " ", titulo).strip(" -_") + return num, titulo + + +def nombre_pieza(num: int, titulo: str) -> str: + """convención de la casa: «NN- Título» (la forma más usada en las carpetas actuales)""" + return f"{num:02d}- {titulo}" + + +_RX_ENVATO_DIR = re.compile(r"-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-utc$", re.I) + + +# -------------------------------------------------------------------------- +# Firmas de archivos: de qué cámara / herramienta viene +# -------------------------------------------------------------------------- + +FIRMAS = [ + ("URSA_BRAW", r"^[A-Z]\d{3}_\d{8}_C\d{3}(_\d+)?\.braw$", "Blackmagic (BRAW)"), + ("BM_PROXY", r"^[A-Z]\d{3}_\d{8}_C\d{3}(_\d+)?\.(mp4|mov)$", "Blackmagic proxy H.264 / ProRes"), + ("SONY", r"^\d{8}_labstream\d{4}(m01)?\.(mp4|xml)$", "Sony XAVC"), + ("SONY", r"^c\d{4}(m01)?\.(mp4|mov|xml)$", "Sony"), + ("OSMO", r"^dji_\d{14}_\d{4}_d\.(mp4|mov|lrf)$", "DJI Osmo Pocket"), + ("DJI", r"^dji_\d{4}\.(mp4|mov|jpg|dng|lrf|srt)$", "DJI dron / Osmo"), + ("DJI_MIC", r"^dji_\d{2}_\d{8}_\d{6}\.wav$", "DJI Mic"), + ("TASCAM", r"^tascam_\d{4}\.wav$", "Tascam"), + ("ATOMOS", r"^ninjav_", "Atomos Ninja V"), + ("CELULAR", r"^img_\d{4}( \(\d+\))?( \d)?(_[a-z0-9]+)?\.(mov|mp4|heic|jpg|jpeg|png)$", "iPhone"), + ("CELULAR", r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(mov|heic|jpg|jpeg|mp4|png)$", "iPhone (AirDrop)"), + ("CELULAR", r"^(vid|pxl)_\d{8}", "Android"), + ("WHATSAPP", r"^whatsapp (image|video|audio) ", "WhatsApp"), + ("PANASONIC", r"^_?\d{7}\.(rw2|jpg)$", "Panasonic Lumix"), + ("SONY_FOTO", r"^dsc\d{5}\.(arw|jpg)$", "Sony foto"), + ("IA_HIGGSFIELD", r"^hf_\d{8}_\d{6}_", "Higgsfield"), + ("IA_CHATGPT", r"^chatgpt image ", "ChatGPT"), + ("IA_VIDEO", r"^(animate_this_image|video_\d|kling|runway|veo_|sora)", "IA video"), + ("IA_VOZ", r"^elevenlabs_", "ElevenLabs"), + ("STOCK", r"-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-utc(\b|[ .(])", "Envato Elements"), + ("CAPTURA", r"^(captura de pantalla|grabaci[oó]n de pantalla|screenshot|screen recording|grabaci[oó]n \d{4})", "Captura de pantalla"), +] +FIRMAS_RE = [(k, re.compile(p, re.I), d) for k, p, d in FIRMAS] + +FUENTE_CARPETA = { # nombre de subcarpeta al separar una sesión de grabación por fuente + "URSA_BRAW": "URSA_BRAW", "BM_PROXY": "BLACKMAGIC_MP4", "SONY": "SONY", "OSMO": "OSMO", + "DJI": "DJI", "DJI_MIC": "AUDIO", "TASCAM": "AUDIO", "ATOMOS": "ATOMOS", + "CELULAR": "CELULAR", "WHATSAPP": "CELULAR", "PANASONIC": "FOTOS", "SONY_FOTO": "FOTOS", +} + + +def firma_archivo(nombre: str): + for k, rx, desc in FIRMAS_RE: + if rx.search(nombre): + return k + return None + + +# -------------------------------------------------------------------------- +# Extensiones +# -------------------------------------------------------------------------- + +EXT = { + "video": {".mov", ".mp4", ".m4v", ".mxf", ".mts", ".avi", ".mkv", ".webm", ".braw", ".r3d", ".m2ts"}, + "video_raw": {".braw", ".r3d", ".ari"}, + "audio": {".wav", ".mp3", ".aac", ".m4a", ".aif", ".aiff", ".flac", ".ogg", ".wma"}, + "imagen": {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp", ".svg"}, + "foto_raw": {".arw", ".rw2", ".dng", ".cr2", ".cr3", ".nef", ".raf", ".xmp"}, + "diseno": {".psd", ".psb", ".ai", ".af", ".afphoto", ".afdesign", ".indd", ".xd", ".fig", ".sketch", ".eps"}, + "proyecto": {".drp", ".drb", ".drt", ".dra", ".prproj", ".aep", ".prin", ".fcpxml", ".aaf", ".edl", ".otio", ".xml", ".comp", ".setting", ".mogrt", ".drfx"}, + "documento": {".pdf", ".docx", ".doc", ".md", ".txt", ".pptx", ".xlsx", ".csv", ".rtf", ".ttml", ".srt", ".vtt", ".pages", ".key", ".numbers"}, + "fuente": {".ttf", ".otf", ".woff", ".woff2"}, + "comprimido": {".zip", ".rar", ".7z", ".tar", ".gz", ".dmg", ".iso", ".img"}, + "lut": {".cube", ".3dl", ".look"}, + "programa": {".exe", ".msi", ".pkg", ".app", ".lnk"}, +} +BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"} +BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk", ".bak", ".ims", ".idx", ".dat"} +BASURA_DIRS = {"__macosx", "#recycle", ".synologyworkingdirectory", "@eadir", "media cache", "media cache files", "peak files"} +BASURA_DIRS_CONTIENE = ["almacenamiento automatico", "auto save", "autosave", "auto-save", "adobe premiere pro auto"] +PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media", "cache"} + + +def es_basura_archivo(nombre: str) -> bool: + n = nombre.lower() + if n in BASURA_NOMBRES or n.startswith("._"): + return True + if n.endswith("~lock~") or n.endswith("~"): + return True + ext = n[n.rfind("."):] if "." in n else "" + if ext in BASURA_EXT: + return True + if re.match(r"^ps[a-z0-9]{3,5}\.tmp$", n) or re.match(r"^adobe photoshop \d{10,}$", n): + return True + if re.match(r"^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\.tmp$", n): + return True + return False + + +def es_basura_dir(nombre: str) -> bool: + n = norm(nombre) + if n in BASURA_DIRS or nombre.lower() in BASURA_DIRS: + return True + return any(t in n for t in BASURA_DIRS_CONTIENE) + + +def es_proxy_dir(nombre: str) -> bool: + return norm(nombre) in PROXY_DIRS or nombre.lower() in {".proxy"} + + +def rol_archivo(nombre: str, ext: str, firma: str | None) -> str: + """Qué es el archivo por sí mismo (sin mirar la carpeta).""" + e = ext.lower() + if es_basura_archivo(nombre): + return "basura" + if firma in ("URSA_BRAW",) or e in EXT["video_raw"]: + return "camara_raw" + if firma in ("BM_PROXY", "SONY", "OSMO", "DJI", "ATOMOS") and e in EXT["video"]: + return "camara_video" + if firma == "SONY" and e == ".xml": + return "camara_sidecar" + if firma in ("CELULAR", "WHATSAPP") and e in EXT["video"]: + return "celular_video" + if firma in ("CELULAR", "WHATSAPP") and e in EXT["imagen"]: + return "celular_foto" + if firma in ("DJI_MIC", "TASCAM"): + return "audio_grabado" + if firma in ("PANASONIC", "SONY_FOTO") or e in EXT["foto_raw"]: + return "foto_camara" + if firma and firma.startswith("IA_"): + return "ia" + if firma == "STOCK": + return "stock" + if firma == "CAPTURA": + return "captura" + if e == ".lrf": + return "proxy" + if e in EXT["video"]: + return "video" + if e in EXT["audio"]: + return "audio" + if e in EXT["imagen"]: + return "imagen" + if e in EXT["diseno"]: + return "diseno" + if e in EXT["proyecto"]: + return "proyecto" + if e in EXT["documento"]: + return "documento" + if e in EXT["fuente"]: + return "fuente" + if e in EXT["comprimido"]: + return "comprimido" + if e in EXT["lut"]: + return "lut" + if e in EXT["programa"]: + return "programa" + return "otro" + + +# -------------------------------------------------------------------------- +# Fecha a partir del nombre del archivo +# -------------------------------------------------------------------------- + +_RX_YMD = re.compile(r"(? cubeta. Se siembra en datos/diccionario.json y de ahí aprende. +DICCIONARIO_SEMILLA = { + # grabación + "grabacion": "GRABACION", "grabaciones": "GRABACION", "grabado": "GRABACION", "grabados": "GRABACION", + "tomas": "GRABACION", "rushes": "GRABACION", "brutos": "GRABACION", "raw": "GRABACION", + "ursa": "GRABACION", "braw": "GRABACION", "sony": "GRABACION", "osmo": "GRABACION", "dji": "GRABACION", "blackmagic": "GRABACION", + "blackmagic braw": "GRABACION", "otros blackmagic": "GRABACION", "otro sony": "GRABACION", "dji audio": "GRABACION", + "tascam": "GRABACION", "camara": "GRABACION", "videos originales": "GRABACION", "originales": "GRABACION", + # exportados + "render": "EXPORTADOS", "renders": "EXPORTADOS", "export": "EXPORTADOS", "exportado": "EXPORTADOS", + "exportados": "EXPORTADOS", "exports": "EXPORTADOS", "finales": "EXPORTADOS", "final": "EXPORTADOS", + "videos finales": "EXPORTADOS", "videos finales pantalla": "EXPORTADOS", "finales enviar": "EXPORTADOS", + "entrega": "EXPORTADOS", "entregas": "EXPORTADOS", "master": "EXPORTADOS", "masters": "EXPORTADOS", + "entregable": "EXPORTADOS", "entregables": "EXPORTADOS", "publicados": "EXPORTADOS", + # portadas + "portada": "PORTADAS", "portadas": "PORTADAS", "miniatura": "PORTADAS", "miniaturas": "PORTADAS", + "thumbnails": "PORTADAS", "thumbnail": "PORTADAS", "covers": "PORTADAS", "cover": "PORTADAS", + # marca + "material de marca": "MARCA", "marca": "MARCA", "manual de marca": "MARCA", "branding": "MARCA", + "logos": "MARCA", "logo": "MARCA", "logos png": "MARCA", "fuente": "MARCA", "fuentes": "MARCA", + "tipografia": "MARCA", "tipografias": "MARCA", "fondo": "MARCA", "fondos": "MARCA", + "transitions": "MARCA", "transiciones": "MARCA", "efectos": "MARCA", "texto": "MARCA", "textos": "MARCA", + "intro": "MARCA", "intros": "MARCA", "plantillas": "MARCA", "luts": "MARCA", + # proyectos + "adobe": "PROYECTOS", "proyecto": "PROYECTOS", "proyectos": "PROYECTOS", "premiere": "PROYECTOS", + "resolve": "PROYECTOS", "after": "PROYECTOS", "after effects": "PROYECTOS", "photoshop": "PROYECTOS", + "animaciones": "PROYECTOS", "prerenders": "PROYECTOS", "socalo": "PROYECTOS", + # música / sonido / voz + "musica": "MUSICA", "music": "MUSICA", "canciones": "MUSICA", "soundtrack": "MUSICA", "julio music": "MUSICA", + "sonidos": "SFX", "sfx": "SFX", "swoosh": "SFX", "whoosh": "SFX", "efectos de sonido": "SFX", + "voces": "VOZ", "voz": "VOZ", "elevenlabs": "VOZ", "locucion": "VOZ", "locuciones": "VOZ", "narracion": "VOZ", + "audio": "VOZ", "audios": "VOZ", + # ia + "ia": "IA", "ai": "IA", "imagenes ia": "IA", "imagenes ia importante": "IA", "avatars": "IA", "avatares": "IA", + "higgsfield": "IA", "chatgpt": "IA", "generado": "IA", + # stock + "stock": "STOCK", "envato": "STOCK", "recursos": "STOCK", "elements": "STOCK", "recursos audiovisuales": "STOCK", + # guion / documentos + "guion": "GUION", "guiones": "GUION", "script": "GUION", "scripts": "GUION", + "propuesta": "DOCUMENTOS", "propuestas": "DOCUMENTOS", "referentes": "DOCUMENTOS", "referencia": "DOCUMENTOS", + "referencias": "DOCUMENTOS", "imagen referencia": "DOCUMENTOS", "brief": "DOCUMENTOS", "documentos": "DOCUMENTOS", + "claude": "DOCUMENTOS", "conferencia": "DOCUMENTOS", + # fotos + "fotos": "FOTOS", "fotografias": "FOTOS", "fotografia": "FOTOS", "jpeg": "FOTOS", "imagenes": "FOTOS", "imagen": "FOTOS", + # apoyos + "tomas de apoyo": "APOYOS", "tomas apoyos": "APOYOS", "apoyos": "APOYOS", "apoyo": "APOYOS", "b roll": "APOYOS", + "broll": "APOYOS", "rellenos": "APOYOS", + # campañas de marca + "dove": "CAMPANA", "loreal": "CAMPANA", "garnier": "CAMPANA", "isdin": "CAMPANA", "cetaphil": "CAMPANA", + "artemis": "CAMPANA", "vinicius": "CAMPANA", "the organic pharmacy": "CAMPANA", "nixoderm": "CAMPANA", + "publicidad": "CAMPANA", "pautas facebook": "CAMPANA", "pauta": "CAMPANA", "campana": "CAMPANA", + "campanas": "CAMPANA", "anuncio": "CAMPANA", "ads": "CAMPANA", "vsl": "CAMPANA", + "vsls": "CAMPANA", "lanzamientos": "CAMPANA", "tanda": "CAMPANA", + # celular + "celular": "CELULAR", "material celular": "CELULAR", "iphone": "CELULAR", "calular pero de": "CELULAR", + # proxies y basura + "proxy": "PROXY", "proxies": "PROXY", "macosx": "BASURA", "recycle": "BASURA", + # contenedores: hay que mirar adentro + "nuevo": "CONTENEDOR", "nuevo orden": "CONTENEDOR", "nuevo 2026": "CONTENEDOR", "anuncios": "CONTENEDOR", + "material": "IGNORAR", # "Material" es estructura (de una campaña o de la raíz de pauta): no se mueve + "otros videos": "CONTENEDOR", "otros": "CONTENEDOR", "varios": "CONTENEDOR", "sin fecha": "CONTENEDOR", + "temporal": "CONTENEDOR", "temp": "CONTENEDOR", "desordenado": "CONTENEDOR", "desordenado revisar": "CONTENEDOR", + "sacado de nubes": "CONTENEDOR", "cambio de proyecto": "CONTENEDOR", "nueva carpeta": "CONTENEDOR", + "repetidos": "IGNORAR", "duplicados": "IGNORAR", "backup": "REVISAR", "respaldo": "REVISAR", + # cosas del cliente que son piezas o series + "reels": "PIEZA", "reel": "PIEZA", "historia": "PIEZA", "historias": "PIEZA", "carrusel": "PIEZA", + "podcast": "PIEZA", "consultorio": "PIEZA", "testimonios": "PIEZA", "clases": "PIEZA", + # subcarpetas normales DENTRO de una pieza: se dejan como están + "visual": "IGNORAR", "visula": "IGNORAR", "01 visual": "IGNORAR", "chroma key": "IGNORAR", + "prerenders": "IGNORAR", "elements": "STOCK", "elemnts": "STOCK", "stems": "MUSICA", "loops": "MUSICA", + "shorts": "MUSICA", "full": "MUSICA", "exportada": "EXPORTADOS", "higgsfiel": "IA", +} + +# sub-carpeta canónica dentro de 00_Marca +MARCA_SUB = { + "fuente": "Fuentes", "fuentes": "Fuentes", "tipografia": "Fuentes", "tipografias": "Fuentes", + "fondo": "Fondos", "fondos": "Fondos", "transitions": "Transiciones", "transiciones": "Transiciones", + "efectos": "Transiciones", "sonidos": "Sonidos", "texto": "Textos", "textos": "Textos", + "logos": "Logos", "logo": "Logos", "logos png": "Logos", "manual de marca": "Manual", "intro": "Intros", + "intros": "Intros", "luts": "LUTs", "plantillas": "Plantillas", +} + + +def cubeta_carpeta(nombre: str, diccionario: dict): + """(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce.""" + if es_basura_dir(nombre): + return "BASURA", "basura de sistema" + if es_proxy_dir(nombre): + return "PROXY", "carpeta de proxies" + if _RX_ENVATO_DIR.search(nombre.strip()): + return "STOCK", "nombre de paquete de Envato" + n = norm(nombre) + if n in diccionario: + return diccionario[n], f"diccionario: «{n}»" + # tokens de varias palabras primero, luego palabras sueltas + # (CONTENEDOR solo por coincidencia exacta: "Material juan manuel" no es un cajón de sastre) + claves = sorted(diccionario.keys(), key=lambda k: -len(k)) + for k in claves: + if " " in k and diccionario[k] != "CONTENEDOR" and re.search(r"(^| )" + re.escape(k) + r"( |$)", n): + return diccionario[k], f"diccionario: contiene «{k}»" + palabras = n.split() + for k in claves: + if " " not in k and diccionario[k] != "CONTENEDOR" and k in palabras: + return diccionario[k], f"diccionario: palabra «{k}»" + return None, "" + + +# palabras que solo dicen "de qué cámara": no aportan etiqueta a una sesión de grabación +FUENTE_PALABRAS = {"grabacion", "grabaciones", "grabado", "grabados", "ursa", "sony", "osmo", "dji", "blackmagic", + "braw", "tascam", "audio", "otro", "otros", "a", "c", "camara", "material", "video", "videos", + "mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular"} + + +def etiqueta_grabacion(nombre: str) -> str: + """'Grabación Loreal Peeling' -> 'Loreal Peeling'; 'Grabación URSA' -> ''""" + palabras = [p for p in re.sub(r"\s{2,}", " ", nombre).strip(" -_").split(" ") if p] + + def sobra(w): + return w in FUENTE_PALABRAS or w.isdigit() or MESES_ALIAS.get(w, w) in MESES or w in ("-", "_") + utiles = [p for p in palabras if not all(sobra(w) for w in norm(p).split()) and norm(p)] + if not utiles: + return "" + return " ".join(utiles) + + +# -------------------------------------------------------------------------- +# Plantillas de estructura por tipo de cliente +# -------------------------------------------------------------------------- + +TIPOS_CLIENTE = { + "contenido": "Contenido mensual (dermatólogas, marca personal): año / mes / pieza", + "pauta": "Pauta y anuncios (campañas por tandas, ganchos, VSL)", + "proyecto": "Proyecto puntual corporativo (un trabajo, una entrega)", + "interno": "Interno de Labstream (recursos compartidos)", +} + +# Destinos relativos a la raíz del cliente. {anio} {mes} = "08-Agosto" {nombre} {fuente} +PLANTILLAS = { + "contenido": { + "MARCA": "00_Marca/{sub}", + "PROYECTOS": "01_Proyectos/{nombre}", + "GRABACION": "{anio}/{mes}/00_Grabacion/{sub}", + "PIEZA": "{anio}/{mes}/{nombre}", + "CAMPANA": "Campañas/{anio}_{nombre}", + "EXPORTADOS": "{anio}/{mes}/_Exportados/{sub}", + "PORTADAS": "{anio}/{mes}/_Portadas/{sub}", + "APOYOS": "_Apoyos/{sub}", + "MUSICA": "_Recursos/Musica/{sub}", + "SFX": "_Recursos/Sonidos/{sub}", + "VOZ": "{anio}/{mes}/_Voces/{sub}", + "IA": "{anio}/{mes}/_IA/{sub}", + "STOCK": "_Recursos/Stock/{sub}", + "GUION": "{anio}/{mes}/_Guiones/{sub}", + "FOTOS": "{anio}/{mes}/_Fotos/{sub}", + "DOCUMENTOS": "_Documentos/{sub}", + "CELULAR": "{anio}/{mes}/00_Grabacion/CELULAR/{sub}", + "PROXY": "_Para borrar/proxies/{rel}", + "BASURA": "_Para borrar/basura/{rel}", + "REVISAR": "_Revisar/{nombre}", + }, + "pauta": { + "MARCA": "00_Marca/{sub}", + "PROYECTOS": "01_Proyectos/{nombre}", + "GRABACION": "Material/Grabaciones/{anio}/{mes}/{sub}", + "PIEZA": "Campañas/{anio}_{nombre}", + "CAMPANA": "Campañas/{anio}_{nombre}", + "EXPORTADOS": "_Exportados/{anio}/{sub}", + "PORTADAS": "_Portadas/{sub}", + "APOYOS": "Material/Apoyos/{sub}", + "MUSICA": "_Recursos/Musica/{sub}", + "SFX": "_Recursos/Sonidos/{sub}", + "VOZ": "Material/Voces/{sub}", + "IA": "Material/IA/{sub}", + "STOCK": "_Recursos/Stock/{sub}", + "GUION": "_Guiones/{sub}", + "FOTOS": "Material/Fotos/{sub}", + "DOCUMENTOS": "_Documentos/{sub}", + "CELULAR": "Material/Celular/{sub}", + "PROXY": "_Para borrar/proxies/{rel}", + "BASURA": "_Para borrar/basura/{rel}", + "REVISAR": "_Revisar/{nombre}", + }, + "proyecto": { + "MARCA": "00_Marca/{sub}", + "PROYECTOS": "03_Proyecto/{nombre}", + "GRABACION": "02_Grabacion/{anio}/{mes}/{sub}", + "PIEZA": "04_Exportados/{nombre}", + "CAMPANA": "{anio}_{nombre}", + "EXPORTADOS": "04_Exportados/{sub}", + "PORTADAS": "04_Exportados/Portadas/{sub}", + "APOYOS": "01_Material/Apoyos/{sub}", + "MUSICA": "01_Material/Musica/{sub}", + "SFX": "01_Material/Sonidos/{sub}", + "VOZ": "01_Material/Voces/{sub}", + "IA": "01_Material/IA/{sub}", + "STOCK": "01_Material/Stock/{sub}", + "GUION": "00_Brief/{sub}", + "FOTOS": "01_Material/Fotos/{sub}", + "DOCUMENTOS": "00_Brief/{sub}", + "CELULAR": "01_Material/Celular/{sub}", + "PROXY": "_Para borrar/proxies/{rel}", + "BASURA": "_Para borrar/basura/{rel}", + "REVISAR": "_Revisar/{nombre}", + }, + "interno": { + "MARCA": "Marca/{sub}", + "PROYECTOS": "Proyectos/{nombre}", + "GRABACION": "Grabaciones/{anio}/{mes}/{sub}", + "PIEZA": "Piezas/{anio}/{nombre}", + "CAMPANA": "Campañas/{anio}_{nombre}", + "EXPORTADOS": "Exportados/{anio}/{sub}", + "PORTADAS": "Portadas/{sub}", + "APOYOS": "Apoyos/{sub}", + "MUSICA": "Musica/{sub}", + "SFX": "Sonidos/{sub}", + "VOZ": "Voces/{sub}", + "IA": "IA/{sub}", + "STOCK": "Stock/{sub}", + "GUION": "Guiones/{sub}", + "FOTOS": "Fotos/{sub}", + "DOCUMENTOS": "Documentos/{sub}", + "CELULAR": "Celular/{sub}", + "PROXY": "_Para borrar/proxies/{rel}", + "BASURA": "_Para borrar/basura/{rel}", + "REVISAR": "_Revisar/{nombre}", + }, +} + +# La app NUNCA borra: lo que sobra va a esta carpeta dentro de la misma raíz y una persona la revisa y la borra a mano. +PAPELERA = "_Para borrar" +PAPELERAS = ("_Para borrar", "_Papelera") # el nombre viejo se sigue reconociendo en carpetas ya ordenadas +PAPELERAS_NORM = {norm(x) for x in PAPELERAS} +# Los duplicados se juntan en esta subcarpeta AL LADO del archivo que se queda, para revisarlos a mano. +DUPLICADOS = "Duplicados" + +# Carpetas que la app crea y que no debe volver a "ordenar" (se comparan ya normalizadas) +CARPETAS_PROPIAS = {norm(x) for x in ( + "_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas", + "_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo", + "00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados")} + +# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio) +ROL_A_CUBETA = { + "camara_raw": "GRABACION", "camara_video": "GRABACION", "camara_sidecar": "GRABACION", + "audio_grabado": "GRABACION", "celular_video": "CELULAR", "celular_foto": "CELULAR", + "foto_camara": "FOTOS", "ia": "IA", "stock": "STOCK", "captura": "REVISAR", "proxy": "PROXY", + "video": "EXPORTADOS", "audio": "MUSICA", "imagen": "PORTADAS", "diseno": "PROYECTOS", + "proyecto": "PROYECTOS", "documento": "DOCUMENTOS", "fuente": "MARCA", "comprimido": "REVISAR", + "lut": "MARCA", "programa": "REVISAR", "otro": "REVISAR", "basura": "BASURA", +} diff --git a/resolve_bridge.py b/resolve_bridge.py new file mode 100644 index 0000000..ef9eec2 --- /dev/null +++ b/resolve_bridge.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +""" +Puente con DaVinci Resolve Studio: convierte clips BRAW a MP4 H.265 10 bits +usando el codificador NVIDIA de Resolve. + +Se ejecuta como proceso aparte: python resolve_bridge.py lote.json +El lote es una lista de trabajos {id, src, dst, w, h, fps, kbps, color}. +Reporta por stdout una línea JSON por evento: {"id":..,"pct":..,"estado":..} + +Requisitos: Resolve abierto, con "Scripting externo" en Local +(Preferencias > Sistema > General). Usa el proyecto "Labstream Archivo" de la +base de datos actual (lo crea si no existe) y al terminar vuelve al proyecto +que estaba abierto. +""" +import os +import sys +import json +import time +import shutil +from collections import defaultdict + +sys.path.append(r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting\Modules") +os.environ.setdefault("RESOLVE_SCRIPT_API", r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting") +os.environ.setdefault("RESOLVE_SCRIPT_LIB", r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll") + +PROYECTO = "Labstream Archivo" + + +def emitir(**kw): + print(json.dumps(kw, ensure_ascii=False), flush=True) + + +def fps_texto(fps): + if not fps: + return "30" + for candidato in (23.976, 24, 25, 29.97, 30, 47.952, 48, 50, 59.94, 60, 119.88, 120): + if abs(fps - candidato) < 0.02: + return f"{candidato:g}" + return f"{fps:g}" + + +def main(ruta_lote=None): + ruta_lote = ruta_lote or (sys.argv[1] if len(sys.argv) > 1 else None) + if not ruta_lote: + emitir(msg="uso: resolve_bridge.py lote.json") + return 2 + with open(ruta_lote, "r", encoding="utf-8") as f: + trabajos = json.load(f) + try: + import DaVinciResolveScript as dvr + resolve = dvr.scriptapp("Resolve") + except Exception as ex: # noqa + for t in trabajos: + emitir(id=t["id"], estado="error", log=f"no se pudo conectar con Resolve: {ex!r}") + return 1 + if not resolve: + for t in trabajos: + emitir(id=t["id"], estado="error", log="Resolve no responde. ¿Está abierto y con scripting externo en Local?") + return 1 + emitir(msg=f"conectado a {resolve.GetProductName()} {resolve.GetVersionString()}") + if resolve.GetCurrentPage() is None: + for t in trabajos: + emitir(id=t["id"], estado="error", + log="Resolve está en la ventana del Gestor de proyectos (o con un diálogo abierto): " + "abre un proyecto, cierra el diálogo y pulsa «Reintentar errores»") + return 1 + pm = resolve.GetProjectManager() + anterior = pm.GetCurrentProject() + nombre_anterior = anterior.GetName() if anterior else None + if anterior: + try: + pm.SaveProject() + except Exception: # noqa + pass + color = (trabajos[0].get("color") or "rec709") if trabajos else "rec709" + color = color if color in ("rec709", "log") else "rec709" + + # Plan A: proyecto propio «Labstream Archivo» con gestión de color a Rec.709. + proj = None + try: + pm.GotoRootFolder() + proj = pm.LoadProject(PROYECTO) or pm.CreateProject(PROYECTO) + except Exception: # noqa + proj = None + lut_por_clip = None + plan_b = False + if proj: + emitir(msg=f"proyecto «{PROYECTO}» abierto (antes estaba «{nombre_anterior}»)") + ajustes = { + "rec709": { + "colorScienceMode": "davinciYRGBColorManagedv2", + "rcmPresetMode": "Custom", + "separateColorSpaceAndGamma": "0", + "colorSpaceTimeline": "DaVinci WG/Intermediate", + "colorSpaceOutput": "Rec.709 Gamma 2.4", + "inputDRT": "DaVinci", + "outputDRT": "DaVinci", + }, + "log": {"colorScienceMode": "davinciYRGB"}, + }[color] + for k, v in ajustes.items(): + try: + proj.SetSetting(k, v) + except Exception: # noqa + pass + else: + # Plan B: Resolve no deja cambiar de proyecto (biblioteca colaborativa, etc.): + # se trabaja dentro del proyecto abierto, en un bin propio, sin tocar sus ajustes. + proj = anterior + plan_b = True + if not proj: + for t in trabajos: + emitir(id=t["id"], estado="error", log="no hay ningún proyecto abierto en Resolve y no se pudo crear «Labstream Archivo»") + return 1 + gestionado = "colormanaged" in (proj.GetSetting("colorScienceMode") or "").lower() + emitir(msg=f"no se pudo abrir «{PROYECTO}»: se usa el proyecto actual «{nombre_anterior}» (color {'gestionado' if gestionado else 'YRGB'})") + if color == "rec709" and not gestionado: + for nombre_lut in ("Blackmagic Gen 5 Film to Extended Video.cube", "Blackmagic Design Film Gen 5 to Extended Video.cube"): + cand = os.path.join(r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\LUT\Blackmagic Design", nombre_lut) + if os.path.exists(cand): + lut_por_clip = cand + break + emitir(msg="a cada clip BRAW se le aplica la LUT Gen 5 Film → Video para que el archivo se vea bien" if lut_por_clip else "aviso: sin LUT Gen 5 disponible, el archivo quedará en log") + + mp = proj.GetMediaPool() + raiz = mp.GetRootFolder() + bin_archivo = None + for sf in raiz.GetSubFolderList() or []: + if sf.GetName() == PROYECTO: + bin_archivo = sf + break + if not bin_archivo: + bin_archivo = mp.AddSubFolder(raiz, PROYECTO) + lote_bin = mp.AddSubFolder(bin_archivo or raiz, time.strftime("lote %Y-%m-%d %H%M%S")) + if lote_bin: + mp.SetCurrentFolder(lote_bin) + + codecs = proj.GetRenderCodecs("mp4") or {} + codec = "H265_NVIDIA" if "H265_NVIDIA" in codecs.values() else ("H265" if "H265" in codecs.values() else None) + if not codec: + for t in trabajos: + emitir(id=t["id"], estado="error", log=f"Resolve no ofrece H.265 en MP4 (códecs: {list(codecs.values())})") + return 1 + + # agrupar por carpeta de salida + formato: cada grupo es una línea de tiempo + grupos = defaultdict(list) + for t in trabajos: + grupos[(os.path.dirname(t["dst"]), t["w"], t["h"], fps_texto(t["fps"]), t["kbps"])].append(t) + + salida_ok = 0 + for (dst_dir, w, h, fps, kbps), ts in grupos.items(): + ids = [t["id"] for t in ts] + tmp_dir = os.path.join(dst_dir, f"_render_{int(time.time())}") + try: + os.makedirs(tmp_dir, exist_ok=True) + # ajustes de proyecto (sirven de base para la nueva línea de tiempo) + # en el proyecto propio se ajusta la base; en el del editor (plan B) NO se toca ningún ajuste + for k, v in (() if plan_b else (("timelineResolutionWidth", str(w)), ("timelineResolutionHeight", str(h)), ("timelineFrameRate", fps))): + try: + proj.SetSetting(k, v) + except Exception: # noqa + pass + items = mp.ImportMedia([t["src"] for t in ts]) or [] + if len(items) != len(ts): + emitir(msg=f"aviso: se importaron {len(items)} de {len(ts)} clips") + if not items: + for i in ids: + emitir(id=i, estado="error", log="Resolve no pudo importar los clips") + continue + tl = mp.CreateEmptyTimeline(f"archivo {w}x{h} {fps} {int(time.time())}") + if not tl: + for i in ids: + emitir(id=i, estado="error", log="no se pudo crear la línea de tiempo") + continue + for k, v in (("useCustomSettings", "1"), ("timelineResolutionWidth", str(w)), ("timelineResolutionHeight", str(h)), ("timelineFrameRate", fps)): + try: + tl.SetSetting(k, v) + except Exception: # noqa + pass + mp.AppendToTimeline(items) + proj.SetCurrentTimeline(tl) + if lut_por_clip: + try: + for it in tl.GetItemListInTrack("video", 1) or []: + it.SetLUT(1, lut_por_clip) + except Exception as ex: # noqa + emitir(msg=f"aviso: no se pudo aplicar la LUT: {ex!r}") + if not plan_b: + proj.DeleteAllRenderJobs() # solo en el proyecto propio: en el del editor su cola de render es suya + proj.SetCurrentRenderMode(0) # 0 = clips individuales + proj.SetCurrentRenderFormatAndCodec("mp4", codec) + ok = proj.SetRenderSettings({ + "SelectAllFrames": True, + "TargetDir": tmp_dir, + "CustomName": "", + "UniqueFilenameStyle": 1, + "ExportVideo": True, + "ExportAudio": True, + "FormatWidth": int(w), + "FormatHeight": int(h), + "FrameRate": float(fps), + "VideoQuality": int(kbps), + "EncodingProfile": "Main10", + "MultiPassEncode": True, + "AudioCodec": "aac", + "AudioBitDepth": 24, + "AudioSampleRate": 48000, + "NetworkOptimization": True, + }) + if not ok: + emitir(msg="aviso: Resolve rechazó parte de los ajustes de render; sigue con los que aceptó") + job = proj.AddRenderJob() + if not job: + for i in ids: + emitir(id=i, estado="error", log="no se pudo crear el trabajo de render") + continue + proj.StartRendering([job], False) + for i in ids: + emitir(id=i, estado="corriendo", pct=0) + while proj.IsRenderingInProgress(): + st = proj.GetRenderJobStatus(job) or {} + pct = st.get("CompletionPercentage", 0) or 0 + for i in ids: + emitir(id=i, pct=min(99, pct)) + time.sleep(2) + st = proj.GetRenderJobStatus(job) or {} + try: + proj.DeleteRenderJob(job) # el trabajo ya rindió: no se deja en la cola del editor + except Exception: # noqa + pass + if st.get("JobStatus") not in ("Complete",): + for i in ids: + emitir(id=i, estado="error", log=f"Resolve terminó con estado {st.get('JobStatus')}: {st.get('Error', '')}") + continue + # mover los renders a su nombre final + producidos = {os.path.splitext(n)[0].lower(): os.path.join(tmp_dir, n) for n in os.listdir(tmp_dir) if n.lower().endswith(".mp4")} + for t in ts: + base = os.path.splitext(os.path.basename(t["src"]))[0].lower() + src_render = producidos.get(base) + if not src_render: + # Resolve puede añadir sufijos: buscar por prefijo + cands = [p for b, p in producidos.items() if b.startswith(base)] + src_render = cands[0] if cands else None + if not src_render or not os.path.exists(src_render): + emitir(id=t["id"], estado="error", log=f"no apareció el render de {os.path.basename(t['src'])} en {tmp_dir}") + continue + os.makedirs(os.path.dirname(t["dst"]), exist_ok=True) + if os.path.exists(t["dst"]): + # nunca se pisa un archivo existente (podría ser la salida de otro clip con el mismo nombre) + emitir(id=t["id"], estado="error", log=f"ya existe {os.path.basename(t['dst'])}; el render queda en {src_render}") + continue + shutil.move(src_render, t["dst"]) + try: + stt = os.stat(t["src"]) + os.utime(t["dst"], (stt.st_atime, stt.st_mtime)) + except OSError: + pass + salida_ok += 1 + emitir(id=t["id"], estado="listo", pct=100, salida=t["dst"]) + try: + if not os.listdir(tmp_dir): + os.rmdir(tmp_dir) + except OSError: + pass + except Exception as ex: # noqa + for i in ids: + emitir(id=i, estado="error", log=f"error en el puente: {ex!r}") + + if not plan_b: + try: + pm.SaveProject() + except Exception: # noqa + pass + if nombre_anterior and nombre_anterior != PROYECTO and proj.GetName() == PROYECTO: + try: + pm.LoadProject(nombre_anterior) + emitir(msg=f"de vuelta en «{nombre_anterior}»") + except Exception: # noqa + emitir(msg=f"no pude volver a «{nombre_anterior}»; ábrelo a mano") + emitir(msg=f"lote terminado: {salida_ok}/{len(trabajos)} clips") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..a9ffdf7 --- /dev/null +++ b/static/index.html @@ -0,0 +1,1075 @@ + + + + + +OrganiLAB + + + + + + +
+ +
+ + +
+ + +
+

…

+
+
+

Qué hay en la carpeta

+

Últimos cambios en esta carpeta

Todavía no se ha movido nada.
+
+
+
+ + +
+

Revisar y ordenar

+

Todo se mueve dentro de la misma carpeta y nada se borra. ?Basura y proxies van a _Para borrar. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.

+
Esta carpeta todavía no está escaneada. Vuelve a Carpeta y pulsa Escanear (solo lee nombres y tamaños; no toca nada).
+ +
+ + +
+

Convertir a 10 bits

clips de cámara con más de 3 meses y más de 300 MB ·
cambiar
+ + + + + + +
+

Los clips de proyectos ya entregados pasan a MP4 H.265 de 10 bits, unas 10 veces más ligeros. El original no se toca hasta que reemplaces. ?Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. Convertir va en segundo plano, un clip tras otro; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, 10 bits, duración). Al reemplazar, el original se aparta a _Para borrar/originales y el MP4 queda en su lugar con el mismo nombre más «_10bit». Los BRAW van por DaVinci Resolve Studio (debe estar abierto); el resto por ffmpeg con la GPU. Nada se borra; se deshace desde Historial.

+
+
+ + + + + sin actividad + + + + +
+
+
Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.
+
+ + +
+

Respaldar

+

Copia a otro disco una versión compacta del cliente, con los clips ya convertidos en vez de los originales. ?Misma estructura de carpetas, cada una separada por tipo de medio (Video, Audio, Imagenes, Diseño, Proyectos, Documentos). Deja INDICE.csv y _MAPA.md en la raíz del respaldo. Es incremental: lo que ya está igual no se vuelve a copiar. Los originales de cámara sin convertir no se copian salvo que marques «incluir BRAW / RAW».

+
+
+ + + + + + Calcula primero + + +
+
+
+
+

Por tipo de medio

calcula para verlo
+

Originales de cámara que NO se copian (conviértelos primero)

calcula para verlo
+
+

Muestra de cómo queda

calcula para verlo
+

Errores de la copia

ninguno
+
+ + +
+

Historial

+

Cada vez que la app mueve algo lo apunta como un cambio, y Deshacer devuelve cada archivo a donde estaba. ?Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en _Para borrar lo revisa y lo borra una persona desde el Explorador.

+ +
+
+ + +
+

Clientes

+

Un cliente puede tener material en varias carpetas de varios discos; aquí se dice cuáles son y con qué otros nombres aparece. ?Las carpetas registradas son las que la app escanea y ordena. Los otros nombres (alias) sirven para reconocer al cliente en carpetas de otros discos. Casi nunca hace falta tocar nada: al soltar una carpeta en la barra lateral se registra sola.

+
+
Avanzado: plantillas de estructura por tipo de trabajo +
+
Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.
+
+
+
+
+
+
+
+
En curso
+
+ + + + + + diff --git a/static/labstream-blanco.png b/static/labstream-blanco.png new file mode 100644 index 0000000..2de072d Binary files /dev/null and b/static/labstream-blanco.png differ diff --git a/static/labstream.png b/static/labstream.png new file mode 100644 index 0000000..89e3338 Binary files /dev/null and b/static/labstream.png differ diff --git a/static/organilab.ico b/static/organilab.ico new file mode 100644 index 0000000..2ed50ca Binary files /dev/null and b/static/organilab.ico differ diff --git a/static/organilab.png b/static/organilab.png new file mode 100644 index 0000000..1eea7e0 Binary files /dev/null and b/static/organilab.png differ