diff --git a/README.md b/README.md index b3b0443..ef1aa6b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ static/manrope.woff2 ← la tipografía, para no pedirle nada a nadie fuera de WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips -probar.py ← 19 pruebas sin abrir nada · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad +probar.py ← 19 pruebas sin abrir nada · prueba_orden.py: el motor de orden, caso a caso · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad Iniciar.bat ← desde el código, con consola (para ver lo que imprime) ``` @@ -229,7 +229,7 @@ y nunca pisa un MP4 que ya exista; las raíces de unidad (`N:\`) ya se consultan ### Publicar una versión - py -3.12 probar.py && py -3.12 prueba_funcional.py && py -3.12 prueba_ventana.py + py -3.12 probar.py && py -3.12 prueba_orden.py && py -3.12 prueba_funcional.py && py -3.12 prueba_ventana.py py -3.12 empaquetar.py # construye dist\, los dos zips y manifiesto.json (NOTAS="..." para las notas) py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream # el paquete, arrancado por su .exe bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS @@ -757,6 +757,66 @@ son los de verdad. Consecuencias, vistas el 4 oct 2026: - Las pruebas que dan de alta en el registro (`probar.py`, `prueba_ventana.py`) no se ven afectadas: escriben y leen en la misma copia, y además usan una clave de ensayo. +3.2.0, el orden mira la forma de las carpetas, no solo su nombre. Munka pidió pulir la organización de las +carpetas. Antes de tocar nada se midió sobre su carpeta de Andrea Galeano (`V:\Galeano`, 8.207 archivos, +920 GB), el 4 oct 2026, y de ahí sale cada cambio: + +- **«Por nombrar» preguntaba lo que no debía, y contestar hacía daño.** Salían todas las carpetas del + inventario, también las de la propia app (`_Para borrar\basura`, las etiquetas que ella misma crea dentro + de `00_Grabacion`) y las que solo existían ya dentro de la papelera. De las 6 preguntas que tenía esa + carpeta, sobraban las 6; a todas proponía «grabación» porque medía por peso, y los clips pesan cien veces + más que lo demás. Aceptarlas enseñó al diccionario que «orden», «videos» y «basura» eran grabaciones, y + como lo aprendido valía también como palabra suelta dentro de otros nombres, «videos ia» pasó a ser + grabación y el plan se dispuso a desarmar «01- Orden», 533 archivos y 65 GB con proyectos dentro. + Ahora `vocabulario` recorre el inventario igual que el plan recorre el disco y solo pregunta por lo que el + plan llega a ver y no sabe resolver; la propuesta sale de la forma de la carpeta y del número de archivos, + y nunca es «grabación» (una grabación de verdad el plan la reconoce solo). Lo aprendido vale por el nombre + entero; solo las palabras de fábrica valen dentro de otros nombres. Los nombres de la app no se pueden + enseñar, y los que se habían colado se olvidan al arrancar. Sobre la misma carpeta: de 6 preguntas a 0. +- **Una carpeta es una grabación por lo que tiene dentro** (`Estructura.es_rodaje`): casi todo clips de + cámara, y sin las carpetas de una pieza montada (exportados, portada, stock, música, proyecto). Si el + nombre lo dice con una palabra de fábrica basta con que casi todo sea material; si lo dijo alguien en + «Por nombrar», se exige cámara reconocida. Una pieza a la que se llamó grabación ya no se desarma. + Dentro de una grabación, lo que no es rodaje (un proyecto, un guion, una imagen de IA) ya no acaba en + `00_Grabacion\OTROS`: va donde iría suelto; y una subcarpeta de exportados o de música va a lo suyo. Se + conserva el nombre de cada sesión («Tomas consultorio») y la subcarpeta `Duplicados`. +- **Cajones.** Una carpeta que guarda meses o años dentro («Orden», «Proyectos\Mayo», «Edición\11-Noviembre») + no es una cosa que mover: se abre, y lo de dentro se fecha solo, sin heredar el mes donde el cajón esté + metido (`Plan.abrir`). Vale también para una carpeta numerada como pieza cuando alguien confirma que es un + cajón. Solo en clientes cuyas piezas van por mes: una campaña con sus fechas se sigue moviendo entera. + Si la pieza que llega ya existe en ese mes, se junta con ella en vez de dejar una gemela al lado. +- **Repaso de lo ya ordenado** (`Plan.plan_auditoria`). Las carpetas de la estructura no se volvían a + mirar, así que lo que una versión anterior dejó en el mes equivocado se quedaba ahí. En esa carpeta, 385 + clips (464,6 GB) estaban en un mes que no era el de su rodaje: los de diciembre de 2025, en marzo. Se + proponen a su mes los que llevan la fecha en el nombre, y con ellos lo que se grabó ese mismo día. +- **El año de un clip de Blackmagic.** El nombre trae tarjeta, mes, día y hora, pero no año. Antes salía de + la fecha del archivo (que una conversión cambia) o del año de la carpeta (que podía estar mal: mandaba a + diciembre de 2024 lo de diciembre de 2025). Ahora todos los clips de la misma tarjeta y día comparten + año (`_fechas_de_rodaje`): el del que conserve su fecha de cámara, o el más antiguo. Y un rodaje que pasa + de medianoche es uno solo: el del 31 de mayo a las 21:07 seguía a las 04:00 del 1 de junio y salía partido + en dos meses. +- **Un clip convertido sigue siendo de su cámara.** 166 clips que ya habían pasado por el convertidor + («C0001_10bit.mp4») habían perdido su cámara: contaban como «video que no viene de cámara» e iban a OTROS + o a exportados. Y el convertidor no reconocía su propia salida: 22 estaban convertidos dos veces y algunos + tres («…_10bit_10bit_10bit.mp4»). `firma_archivo` mira el nombre sin el sufijo del convertidor (ni el + « (2)» de una copia), y `candidatos` no vuelve a ofrecer lo que ya convirtió. +- **Carpetas gemelas.** `CARPETAS_PROPIAS` comparaba sin el guion bajo, así que una «Exportados» hecha a + mano pasaba por la `_Exportados` de la app y no se tocaba: convivían las dos en el mismo mes. Ahora las + propias salen de las plantillas del tipo de cliente, y la gemela se junta con la buena. Lo mismo con la + papelera: `_Papelera`, el nombre de antes, se junta con `_Para borrar`. +- **«Hacer las N de una vez» solo hace lo marcado.** El material de cámara entraba entero aunque el plan + lo diera por dudoso. La fecha de un original de cámara sí cuenta como probable (es la del rodaje). Lo + dudoso que queda después ya no cuenta como tarea pendiente, y el stock viejo que la lista contaba ahora + se aparta entero (la licencia y el «léeme» de un paquete de Envato se quedaban atrás para siempre). +- **Inventario con versión** (`R.VERSION_REGLAS`). Cambió lo que el escaneo apunta de cada archivo, así + que un inventario hecho con una versión anterior pide «Escanear otra vez» antes de planear nada. + +Comprobado sobre una réplica local de esa carpeta (mismos nombres, carpetas y fechas; archivos de 64 bytes; +el NAS no se tocó): «hacer de una vez» hizo 803 movimientos sin errores ni pérdidas, «01- Orden» quedó vacía +y repartida en sus meses, y una segunda pasada no propuso nada marcado. Pruebas: `prueba_orden.py`, nueva, +19 casos que reproducen cada uno de estos puntos en pequeño; `probar.py` 19 de 19, `prueba_funcional.py` 26 +de 26 y `prueba_ventana.py` 18. + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/asistente.py b/asistente.py index 5b85016..d86b5a6 100644 --- a/asistente.py +++ b/asistente.py @@ -182,7 +182,10 @@ def filas_elegidas(plan_filas, dec): if dec.get("limpieza", "papelera") == "papelera": out.append(f) elif c in ("GRABACION", "CELULAR", "FOTOS"): - if dec.get("material", "ordenar") == "ordenar": + # solo lo marcado: antes entraba todo el material aunque el plan lo diera por dudoso (el mes + # sacado de la fecha de modificación de un archivo que no es de cámara), y «hacer lo seguro» + # movía justo lo que el plan decía que había que mirar + if dec.get("material", "ordenar") == "ordenar" and f["marcado"]: out.append(f) else: m = dec.get("carpetas", "seguras") @@ -222,7 +225,10 @@ def ejecutar(raiz, dec, plan_filas=None, en_hilo=False): 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"] + # las filas de stock cuyo material acaba de irse entero ya no tienen qué mover; las demás + # siguen valiendo (antes se quitaban todas, y una carpeta «Recursos» que no era de Envato + # se quedaba sin ordenar hasta la siguiente pasada) + plan_filas = [f for f in plan_filas if f["cubeta"] != "STOCK" or os.path.exists(f["origen"])] elegidas = filas_elegidas(plan_filas, dec) if elegidas: _prog("ordenar", 0, len(elegidas), "moviendo carpetas y archivos") diff --git a/manifiesto.json b/manifiesto.json index 10f5958..62279c2 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "3.1.1", - "builtAt": "2026-10-04T17:34:05.000Z", - "archivo": "OrganizadorLabstream-portatil-3.1.1.zip", - "size": 195387259, - "sha256": "117da3af85e14222893a34c7f992cd7853536bdef81f634b8453742e33aebdde", + "version": "3.2.0", + "builtAt": "2026-10-04T21:33:00.000Z", + "archivo": "OrganizadorLabstream-portatil-3.2.0.zip", + "size": 195403245, + "sha256": "e029a6010a72817b719fdefc66a9669f11e9cd037a48e8c76f5f91c99f82ee58", "app": { - "archivo": "OrganizadorLabstream-app-3.1.1.zip", - "size": 11574680, - "sha256": "05b9e0f5d35ac1244d8b1b0eb6aa7caefc9799b8d910f3d3ad42d8e22e3c3a2e", - "url": "/api/herramientas/OrganizadorLabstream-app-3.1.1.zip" + "archivo": "OrganizadorLabstream-app-3.2.0.zip", + "size": 11590666, + "sha256": "dcef264edc282a83f00073c6d7bec8aa6a799996e49a255483c1ea6187c6720e", + "url": "/api/herramientas/OrganizadorLabstream-app-3.2.0.zip" }, - "notes": "El programa se instala solo en su sitio definitivo (tu carpeta de programas) y trae un manual dentro, en «Manual». Dos copias ya no pueden compartir puerto, y tras actualizarse Windows enseña siempre la versión nueva.", + "notes": "El orden mira la forma de las carpetas y no solo su nombre: «Por nombrar» pregunta solo lo que importa, una pieza o un cajón con meses dentro ya no se desarman, se repasa lo ya ordenado, y el convertidor no repite lo convertido. Pide escanear otra vez cada carpeta.", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "09ce52a3885faebe34bbb4a8210f460cfa345da578fcb9e73316aec5bdb55ec9977f6cbb9f3d3f8e8925755d6dce94310219d046210fcbcba082134fd088208a9b830086c3bbcbff6acd04259348b70871c935fceb457362619a406f99da2b8abafd69dba7e1400020b90fd7a211d42e72cd49cd0294fbc8fc30e8fbae0ba431e5881f68a6607de7ae44ceadb5b22b007c7a4e5a95347ebdab5b1a645c4814b51a01f95c57e2c0caf9a5a0ccf35ca2f9f53117788b509ff42f4fd8800fe9f38eecb5ee421f3e6dc366b48d344291b21c1b50677fb50e8621733b30d932e0f055b07571b74a0273e528797ae3853851ce35e0eb9a42fe14ae34ca6ea7a6ff2029" + "valor": "2d0254434190db0e98469d7988923419aff8364bf0b72378aa528dfa0ef3fb5abf9c1620b11ebfa7ba679f9e7863c81edab027c0ce783f31fb61032b938ecee86e0f3bdf9a8d999cebe1701668983c4b3df48db2061ff011ab1a9b026ca2d6b124fdf92b6ea6df0d75d69c41d7e6ac3235bd9b8a058cf20b98bed026d2ff574802314b6542eeed1f89df914f777c3d06c0aaa9c656b64972edd274cb1b41c2baf94c9d4d05381241c56e4b621681ec8eafb29f88b7c64a111665c47247b05e584f19f4e70ee06ee3d163600dac3f70515f8fec4d09dfc1c151337058189bf02d5f5064112ee4d0989c2118d7f5e981a9fa77938d5aa8e839c58092250a1f29ac" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index afd9051..9784be1 100644 --- a/organizador.py +++ b/organizador.py @@ -40,7 +40,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "3.1.1" +VERSION = "3.2.0" BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) # Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es @@ -239,6 +239,17 @@ for k, v in CONFIG_DEFECTO.items(): # completar claves nuevas sin pisar guardar_json(CONFIG_PATH, CONFIG) +def _sufijos_conversion(): + """El sufijo del convertidor se puede cambiar en Ajustes: las reglas tienen que conocerlo para saber + que «C0001_ligero2.mp4» sigue siendo el clip C0001 y no un exportado.""" + propio = (CONFIG["optimizar"].get("sufijo") or "").strip().lower() + if len(propio) > 2 and propio not in R.SUFIJOS_CONVERSION: + R.SUFIJOS_CONVERSION = R.SUFIJOS_CONVERSION + (propio,) + + +_sufijos_conversion() + + 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.""" @@ -265,6 +276,11 @@ 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) +# Hasta la 3.1.1 «Por nombrar» preguntaba también por las carpetas de la propia app («basura», de +# _Para borrar\basura) y proponía «grabación» si dentro había clips. Lo que se aprendió así se olvida: +# nunca fue una carpeta del cliente. +for k in [k for k in DICC["carpetas"] if k in R.NOMBRES_DE_LA_APP and k not in R.DICCIONARIO_SEMILLA]: + del DICC["carpetas"][k] guardar_json(DICC_PATH, DICC) _act = CONFIG.get("actualizaciones") @@ -349,7 +365,9 @@ def init_db(): con.commit() for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'", - "ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1"): + "ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1", + # con qué versión de las reglas se hizo el inventario (R.VERSION_REGLAS) + "ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0"): try: con.execute(alter) con.commit() @@ -431,13 +449,23 @@ def escanear(ruta): dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre] props = ("n", "bytes") - def agrega_dir(d, size, fecha_am): + def agrega_dir(d, size): 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 subir(d): + """`d` y todas las carpetas de encima, hasta la raíz.""" + while True: + yield d + if os.path.normpath(d).lower() == raiz.lower(): + return + padre = os.path.dirname(d) + if padre == d: + return + d = padre def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None): # `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows @@ -498,20 +526,12 @@ def escanear(ruta): 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, R.fecha_real(st)) - 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 + for d in subir(path): + agrega_dir(d, st.st_size) except OSError as ex: fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})") continue @@ -519,7 +539,16 @@ def escanear(ruta): try: walk(raiz, False, False, False, "", "raiz", None, None) prog["fallos"] = len(fallos) + _fechas_de_rodaje(filas) _heredar_fechas(filas) + # de qué mes es cada carpeta: se cuenta al final, con las fechas ya corregidas, y sin la basura + # ni los proxies (un .DS_Store de otro año no dice nada del material) + for f in filas: + if f[14] or f[15]: + continue + am = (int(f[12][:4]), int(f[12][5:7])) + for d in subir(f[2]): + dirs[d][2][am] += 1 con = db() # Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de # red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de @@ -554,7 +583,7 @@ def escanear(ruta): n, b, fechas, p, bs, nombre = info if fechas: (ay, am), cnt = fechas.most_common(1)[0] - frac = cnt / n + frac = cnt / sum(fechas.values()) claves = sorted(fechas) d0 = datetime.date(claves[0][0], claves[0][1], 1) d1 = datetime.date(claves[-1][0], claves[-1][1], 1) @@ -563,8 +592,8 @@ def escanear(ruta): 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.execute("INSERT OR REPLACE INTO escaneos (raiz, ts, n, bytes, seg, reglas) VALUES (?,?,?,?,?,?)", + (raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"], R.VERSION_REGLAS)) con.close() prog["estado"] = "listo" if fallos: @@ -591,15 +620,18 @@ def _heredar_fechas(filas): en_todo[tallo].append(f) heredadas = 0 for i, f in enumerate(filas): - base = R.base_derivado(f[I_NOMBRE]) - if not base: - continue - orig = por_dir[f[I_DIR].lower()].get(base.lower()) - if orig is None and len(base) >= 6: - # el render puede estar en otra carpeta («_Exportados», «_Archivo»): vale si el nombre es - # bastante propio y no hay dos candidatos que se llamen igual en toda la carpeta del cliente - cands = en_todo.get(base.lower()) or [] - orig = cands[0] if len(cands) == 1 else None + orig = None + # primero el original del todo («C0001» para «C0001_10bit_10bit.mp4») y, si no está, el de un + # paso atrás: una conversión de una conversión no debe quedarse con la fecha de la primera + for base in dict.fromkeys(b for b in (R.raiz_derivado(f[I_NOMBRE]), R.base_derivado(f[I_NOMBRE])) if b): + orig = por_dir[f[I_DIR].lower()].get(base.lower()) + if orig is None and len(base) >= 6: + # el render puede estar en otra carpeta («_Exportados», «_Archivo»): vale si el nombre es + # bastante propio y no hay dos candidatos que se llamen igual en toda la carpeta del cliente + cands = en_todo.get(base.lower()) or [] + orig = cands[0] if len(cands) == 1 else None + if orig is not None: + break if orig is None or orig is f or orig[I_ORIGEN] == "derivado": continue if orig[I_FECHA] >= f[I_FECHA]: @@ -612,6 +644,66 @@ def _heredar_fechas(filas): return heredadas +def _fechas_de_rodaje(filas): + """Los clips de Blackmagic se llaman por la tarjeta, el mes y el día («A014_1209…»), sin año. El año + sale de la fecha del archivo, y esa cambia cuando una conversión o una copia lo reescriben. + + Todos los clips de la misma tarjeta y el mismo día son del mismo rodaje, así que comparten año: si + uno conserva su fecha de cámara, ese año es seguro para todos; si ninguno, vale el más antiguo, porque + un clip no puede ser posterior a ninguna de sus copias. Medido el 4-oct-2026 sobre el material real: + un mismo rodaje del 2 de marzo estaba partido entre 2025 y 2026 solo porque la mitad de los clips se + había vuelto a guardar en julio.""" + I_NOMBRE, I_FECHA, I_ORIGEN = 3, 12, 13 + grupos = defaultdict(list) + for i, f in enumerate(filas): + if f[I_ORIGEN] in ("nombre", "nombre+mtime"): + clave = R.clave_rodaje(f[I_NOMBRE]) + if clave: + grupos[clave].append(i) + cambiadas = 0 + for idx in grupos.values(): + seguras = {filas[i][I_FECHA] for i in idx if filas[i][I_ORIGEN] == "nombre"} + if len(seguras) > 1: + continue # la misma tarjeta y día en dos años distintos: no se decide + fecha = next(iter(seguras)) if seguras else min(filas[i][I_FECHA] for i in idx) + origen = "nombre" if seguras else "nombre+mtime" + for i in idx: + f = filas[i] + if f[I_FECHA] == fecha and f[I_ORIGEN] == origen: + continue + nueva = list(f) + nueva[I_FECHA], nueva[I_ORIGEN] = fecha, origen + filas[i] = tuple(nueva) + cambiadas += 1 + # Un rodaje que pasa de medianoche es un solo rodaje. El nombre trae también la hora («A006_05312107», + # «A006_06010000»): lo grabado de madrugada cuenta como del día en que se empezó, si ese día se + # estaba grabando por la noche. Medido el 4-oct-2026: un rodaje del 31 de mayo a las 21:07 al 1 de + # junio a las 04:00 salía partido en dos meses, 63 clips en mayo y 103 en junio. + de_noche = set() + for idx in grupos.values(): + for i in idx: + try: + if int(filas[i][I_NOMBRE][9:11]) >= 18: + de_noche.add(filas[i][I_FECHA]) + except ValueError: + pass + for idx in grupos.values(): + for i in idx: + f = filas[i] + try: + if int(f[I_NOMBRE][9:11]) >= 6: + continue + vispera = (datetime.date.fromisoformat(f[I_FECHA]) - datetime.timedelta(days=1)).isoformat() + except ValueError: + continue + if vispera in de_noche: + nueva = list(f) + nueva[I_FECHA] = vispera + filas[i] = tuple(nueva) + cambiadas += 1 + return cambiadas + + def lanzar_escaneo(ruta): raiz = os.path.normpath(ruta) p = PROGRESO.get(raiz) @@ -675,6 +767,161 @@ def destino_plantilla(tipo, cubeta, **kw): return os.sep.join(segmentos) +# ========================================================================== +# Qué es una carpeta por lo que tiene dentro +# ========================================================================== + +def _mes_claro(nombre): + """True si la carpeta se llama como un mes sin lugar a dudas («Mayo», «11-Noviembre», «Marzo 2026»). + «Mar», «May» o «05» pueden ser otra cosa y no cuentan.""" + m, dudoso = R.detectar_mes(re.sub(r"20\d\d", " ", nombre or "").strip(" -_")) + return bool(m) and not dudoso + + +class Estructura: + """El árbol de una carpeta de cliente tal como quedó en el inventario, con lo que cuelga de cada rama. + + El nombre de una carpeta no basta para saber qué es. Medido el 4-oct-2026 sobre el material real: + «Orden» (533 archivos, 65 GB) se aprendió como grabación porque casi todo su peso eran clips, y el plan + la iba a desarmar entera por cámara y por mes; dentro había una organización anterior completa, con + sus grabaciones, sus proyectos por mes y sus portadas. Aquí se mira la forma: una tarjeta volcada es + casi todo clips de cámara; una pieza trae su carpeta de exportados, de portada y de música; y una + carpeta que guarda meses o años dentro es un cajón que hay que abrir, no una cosa que mover.""" + + def __init__(self, con, raiz, dicc): + self.raiz = os.path.normpath(raiz) + self.dicc = dicc + self.carp = {} # ruta (normcase) -> fila de `carpetas` + self.hijos_de = defaultdict(list) # ruta (normcase) -> rutas de sus subcarpetas + tope = os.path.normcase(self.raiz) + for c in con.execute("SELECT * FROM carpetas WHERE raiz = ?", (self.raiz,)).fetchall(): + k = os.path.normcase(os.path.normpath(c["ruta"])) + self.carp[k] = c + if k != tope: + self.hijos_de[os.path.normcase(os.path.dirname(os.path.normpath(c["ruta"])))].append(os.path.normpath(c["ruta"])) + # por carpeta, contando lo de sus subcarpetas: [archivos, de cámara, de rodaje, proyectos] + self.cuenta = defaultdict(lambda: [0, 0, 0, 0]) + self.anios = defaultdict(Counter) # ruta -> cuántos archivos de cada año hay debajo + for f in con.execute("SELECT dir, rol, fuente, substr(fecha, 1, 4) anio, COUNT(*) n FROM archivos WHERE raiz = ? " + "AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol, fuente, anio", (self.raiz,)).fetchall(): + cam = f["fuente"] in R.FUENTE_CARPETA or f["rol"] in R.ROLES_CAMARA + rod = f["rol"] in R.ROLES_RODAJE + proy = f["rol"] in ("proyecto", "diseno") + d = os.path.normpath(f["dir"]) + while True: + e = self.cuenta[os.path.normcase(d)] + e[0] += f["n"] + e[1] += f["n"] if cam else 0 + e[2] += f["n"] if rod else 0 + e[3] += f["n"] if proy else 0 + if f["anio"]: + self.anios[os.path.normcase(d)][f["anio"]] += f["n"] + if os.path.normcase(d) == tope: + break + padre = os.path.dirname(d) + if padre == d: + break + d = padre + + def _k(self, ruta): + return os.path.normcase(os.path.normpath(ruta)) + + def utiles(self, ruta): + """Archivos que hay debajo, sin contar basura ni proxies.""" + return self.cuenta[self._k(ruta)][0] if self._k(ruta) in self.cuenta else 0 + + def hijos(self, ruta): + return self.hijos_de.get(self._k(ruta), []) + + def anio_de(self, ruta): + """El año del que es la mayor parte de lo que hay debajo, o None si la carpeta está vacía.""" + c = self.anios.get(self._k(ruta)) + if not c: + return None + try: + return int(c.most_common(1)[0][0]) + except ValueError: + return None + + def debajo(self, ruta, hondo=3): + """Las subcarpetas hasta `hondo` niveles.""" + nivel = [os.path.normpath(ruta)] + for _ in range(hondo): + nivel = [h for p in nivel for h in self.hijos(p)] + if not nivel: + return + yield from nivel + + def fechas_dentro(self, ruta, hondo=3): + """Carpetas de año o de mes que hay debajo y tienen material: la señal de que esto guarda varias + cosas de distintas fechas y hay que mirarlo por dentro.""" + return [d for d in self.debajo(ruta, hondo) + if self.utiles(d) and (R.detectar_anio(os.path.basename(d)) or _mes_claro(os.path.basename(d)))] + + def partes(self, ruta, hondo=3): + """Qué partes de una pieza montada hay entre sus subcarpetas (exportados, portada, stock, música…).""" + out = set() + for d in self.debajo(ruta, hondo): + if not self.utiles(d): + continue + cub, _ = R.cubeta_carpeta(os.path.basename(d), self.dicc) + if cub in R.PARTES_PIEZA: + out.add(cub) + return out + + def es_rodaje(self, ruta, por_nombre=False): + """¿Una tarjeta volcada, o una carpeta con algo montado dentro? + + `por_nombre` es True cuando el nombre ya dice que es una grabación con una palabra de fábrica + («Grabación», «URSA», «Tomas»): entonces basta con que casi todo sea material, aunque la cámara + no se reconozca. Si no, se exige que casi todo sean clips de cámara reconocidos.""" + n, cam, rod, proy = self.cuenta[self._k(ruta)] if self._k(ruta) in self.cuenta else (0, 0, 0, 0) + if not n: + return por_nombre + if len(self.partes(ruta)) >= 2: + return False + return rod >= 0.6 * n if por_nombre else cam >= 0.8 * n + + +def _propias(tipo): + """Los nombres de carpeta que ya son de la estructura para este tipo de cliente: los que van en la + raíz y los que van dentro de un mes. Salen de las plantillas en uso, no de una lista fija.""" + primeros, en_mes = set(), set() + for p in PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).values(): + segs = p.split("/") + if "{" not in segs[0]: + primeros.add(segs[0].lower()) + for a, b in zip(segs, segs[1:]): + if a == "{mes}" and "{" not in b: + en_mes.add(b.lower()) + return primeros, en_mes + + +def _es_propia(nombre, nivel, primeros, en_mes): + """True si la carpeta ya es de la estructura y no hay que volver a ordenarla.""" + if nombre.startswith("_") or R.norm(nombre) in R.CARPETAS_PROPIAS: + return True + n = nombre.lower() + return (nivel == "raiz" and n in primeros) or (nivel == "mes" and n in en_mes) + + +def _de_fabrica(nombre, cubeta): + """True si el nombre dice esa cubeta por una palabra de fábrica («Grabación», «URSA») y no porque + alguien lo haya confirmado en «Por nombrar».""" + return R.cubeta_carpeta(nombre, R.DICCIONARIO_SEMILLA)[0] == cubeta + + +def _fecha_de_camara(f): + """La fecha de modificación de un original de cámara es la del rodaje: nadie lo reescribe. No vale + para lo que sale del convertidor ni para lo que no se sabe de qué cámara viene.""" + return (f["fuente"] in R.FUENTE_CARPETA + and f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara") + and not R.es_conversion(f["nombre"])) + + +_RX_DIA = re.compile(r"^\d{2}- (" + "|".join(R.DIAS) + r")$", re.I) + + # ========================================================================== # Planificador # ========================================================================== @@ -691,6 +938,23 @@ class Plan: self.con = db() self.avisos = [] self.dias_mes = self._dias_de_rodaje() + self.est = Estructura(self.con, self.raiz, self.dicc) + # Las carpetas que ya son de la estructura salen de las plantillas de este tipo de cliente, no de + # una lista fija: «Exportados» es canónica en la raíz de un cliente interno y no lo es en la de + # uno de contenido, donde lo canónico es «_Exportados» dentro del mes. + pl = PLANTILLAS.get(self.tipo, PLANTILLAS["contenido"]) + self.primeros, self.en_mes = _propias(self.tipo) + self.por_mes = {c for c, p in pl.items() if "{mes}" in p} # lo que en este tipo de cliente va dentro del mes + self.cajon = 0 # cuántos cajones hay abiertos por encima de donde se está mirando + # Abrir una carpeta porque guarda meses dentro solo tiene sentido donde las piezas se ordenan por + # mes. En un cliente de campañas o de un proyecto puntual, «Campaña X\2025\Noviembre» es una + # campaña con sus fechas, no un cajón: se mueve entera. + self.abre_cajones = "{mes}" in (pl.get("PIEZA") or "") + # dónde vive el material ya ordenado: «{anio}/{mes}/00_Grabacion», «Material/Grabaciones/{anio}/{mes}»… + segs = (pl.get("GRABACION") or "").split("/") + while segs and segs[-1] in ("{dia}", "{sub}", "{fuente}", "{nombre}"): + segs.pop() + self.patron_grab = segs if "{mes}" in segs and "{anio}" in segs else [] def _dias_de_rodaje(self): """Para cada mes, los días distintos en que se grabó algo en esta carpeta de cliente. @@ -796,9 +1060,47 @@ class Plan: self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos) self.sueltos.clear() + def es_propia(self, nombre, nivel): + return _es_propia(nombre, nivel, self.primeros, self.en_mes) + + def pieza_en(self, carpeta_mes, titulo, salvo=None): + """La pieza con ese título que ya hay en un mes («02- Asimetría» para «Asimetría»), o None. + Así el material de una pieza que estaba en otro sitio se junta con ella en vez de abrir una + carpeta gemela al lado.""" + t = R.norm(titulo) + if not t: + return None + + def rango(ruta): + # cuál de dos gemelas se queda: la que ya tiene el nombre de la casa, luego la de número más bajo + nombre = os.path.basename(ruta) + p = R.detectar_pieza(nombre) + canonica = bool(p) and nombre == R.nombre_pieza(p[0], limpiar_nombre(p[1])) + return (0 if canonica else 1, p[0] if p else 99, nombre.lower()) + + mejor = None + for h in self.est.hijos(carpeta_mes): + if salvo and os.path.normcase(h) == os.path.normcase(salvo): + continue + nombre = os.path.basename(h) + p = R.detectar_pieza(nombre) + if R.norm(p[1] if p else nombre) == t and os.path.isdir(h) and (mejor is None or rango(h) < rango(mejor)): + mejor = h + # si las dos están en la misma carpeta, solo se mueve una: la otra es la que se queda y no debe + # proponer a su vez irse con la primera + if mejor and salvo and os.path.normcase(os.path.dirname(mejor)) == os.path.normcase(os.path.dirname(salvo)) \ + and rango(salvo) < rango(mejor): + return None + return mejor + # ---- niveles ---- def correr(self): + esc = self.con.execute("SELECT * FROM escaneos WHERE raiz = ?", (self.raiz,)).fetchone() + if esc and (esc["reglas"] or 0) < R.VERSION_REGLAS: + self.avisos.append("El inventario de esta carpeta se hizo con una versión anterior del programa, que " + "reconocía peor las cámaras y las fechas. Escanéala otra vez antes de aplicar nada.") self.plan_dir(self.raiz, "raiz", None, None) + self.plan_auditoria() self.volcar_sueltos() self.plan_basura() self.con.close() @@ -842,8 +1144,9 @@ class Plan: fila["confianza"] = "baja" fila["marcado"] = False self.avisos.append(f"«{fila['origen_rel']}» y «{otra['origen_rel']}» acabarían las dos en " - f"«{fila['destino_rel']}» con archivos del mismo nombre ({que}). Se dejan sin marcar: " - f"cámbiale el destino a una de las dos, o aplícalas por separado.") + f"«{fila['destino_rel']}» con archivos del mismo nombre ({que}), y dos archivos que se " + f"llaman igual no caben en la misma carpeta. La segunda se mueve; la primera queda sin " + f"marcar para que decidas qué versión se queda.") def entradas(self, ruta): try: @@ -856,25 +1159,45 @@ class Plan: 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): + def plan_dir(self, ruta, nivel, anio, mes, mes_destino=None): + """`mes_destino` es la carpeta de mes a la que va a parar `ruta` cuando ella misma se mueve (un mes + suelto que entra en su año): sirve para juntar sus piezas con las que ya haya allí.""" dirs, files = self.entradas(ruta) # numeración de piezas dentro de un mes - max_num = 0 + num = {"max": 0} if nivel == "mes": + numeros = defaultdict(list) for e in dirs: p = R.detectar_pieza(e.name) if p: - max_num = max(max_num, p[0]) + num["max"] = max(num["max"], p[0]) + numeros[p[0]].append(e.name) + for n, nombres in sorted(numeros.items()): + if len({R.norm(R.detectar_pieza(x)[1]) for x in nombres}) > 1: + self.avisos.append(f"En «{self.rel(ruta)}» hay {len(nombres)} piezas con el número {n:02d}: " + + ", ".join(f"«{x}»" for x in nombres) + + ". La app no las renumera sola: cámbiale el número a una.") 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 + self.plan_subdir(e, nivel, anio, mes, ruta, num, mes_destino) for e in files: self.plan_archivo(e, nivel, anio, mes, ruta) - def plan_subdir(self, e, nivel, anio, mes, padre, max_num): + def abrir(self, e): + """Una carpeta que guarda meses o años dentro no es una cosa que mover: es un cajón. Se abre, y + lo de dentro se ordena por su cuenta, sin heredar el mes en el que el cajón estuviera metido.""" + if not self.cajon: # un cajón dentro de otro no hace falta contarlo otra vez + dentro = self.est.fechas_dentro(e.path) + nombres = list(dict.fromkeys(os.path.basename(d) for d in dentro)) + self.avisos.append(f"«{self.rel(e.path)}» guarda dentro carpetas de otras fechas (" + + ", ".join(f"«{x}»" for x in nombres[:3]) + ("…" if len(nombres) > 3 else "") + + "): la app la abre y ordena lo de dentro, cada cosa a su mes.") + self.cajon += 1 + try: + self.plan_dir(e.path, "contenedor", None, None) + finally: + self.cajon -= 1 + + def plan_subdir(self, e, nivel, anio, mes, padre, num, mes_destino=None): nombre = e.name nn = R.norm(nombre) regla = self.regla_para(e.path) @@ -884,7 +1207,7 @@ class Plan: 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("_"): + if self.es_propia(nombre, nivel): 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))), @@ -919,11 +1242,12 @@ class Plan: 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(" -_") + 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] + # el año, por este orden: el que diga el nombre, el de la mayoría de lo que hay dentro + fa = int(m_anio.group(1)) if m_anio else (self.est.anio_de(e.path) or fecha_carpeta(self.con, e.path)[0]) hoy = datetime.date.today() if fa and (fa, m) > (hoy.year, hoy.month): fa -= 1 @@ -931,7 +1255,7 @@ class Plan: 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) + self.plan_dir(e.path, "mes", fa, m, mes_destino=canon) return if nivel == "contenedor" and anio: m, conflicto = R.detectar_mes(nombre) @@ -939,35 +1263,69 @@ class Plan: # 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) + self.plan_dir(e.path, "mes", anio, m, mes_destino=canon) 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 + # "03 - Grabacion Agosto": es la sesión del mes, no una pieza. Pero solo si de verdad es + # una tarjeta volcada: una pieza a la que alguien llamó grabación no se desarma. + if cub_t == "GRABACION" and self.est.es_rodaje(e.path, por_nombre=_de_fabrica(p[1], "GRABACION")): self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación") return + if cub_t in ("GRABACION", "CONTENEDOR") and self.est.fechas_dentro(e.path): + self.abrir(e) # no es una pieza: es un cajón con cosas de varios meses + return canon = R.nombre_pieza(p[0], limpiar_nombre(p[1])) - if nombre != canon: + gemela = self.pieza_en(mes_destino or padre, p[1], salvo=e.path) + if gemela and os.path.basename(gemela) != nombre: + self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar", + f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media") + elif 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) + if cub_t != "PIEZA" and self.est.fechas_dentro(e.path): + self.avisos.append(f"«{self.rel(e.path)}» está numerada como una pieza, pero dentro guarda carpetas " + f"de varias fechas. Si es un cajón con cosas de distintos meses, dilo en «Por " + f"nombrar» y la app lo abrirá para ordenar lo de dentro.") 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) + if self.est.fechas_dentro(e.path): + # trae sus propios meses o años: lo de dentro se fecha solo, sin heredar el mes de fuera + self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else None, None) + else: + 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 + montada = "" if cub == "GRABACION": - self.plan_grabacion(e, nivel, anio, mes, motivo) + if self.est.es_rodaje(e.path, por_nombre=_de_fabrica(nombre, "GRABACION")): + self.plan_grabacion(e, nivel, anio, mes, motivo) + return + # se llama (o alguien dijo que era) una grabación, pero dentro hay proyectos, exportados o + # portadas: desarmarla por cámara y por mes deshace un trabajo montado + partes = self.est.partes(e.path) + montada = ("tiene nombre de grabación, pero dentro hay algo montado (" + + ", ".join(sorted(_PARTE_FRASE.get(x, x.lower()) for x in partes)) + "): no se desarma" + if len(partes) >= 2 else + "tiene nombre de grabación, pero casi nada de lo que hay dentro es material de cámara: no se reparte") + cub = None + # La forma manda sobre el nombre: una carpeta con meses o años dentro se abre, aunque se llame + # «Proyectos» o «Edición». Vale para lo que no tiene sitio propio y para lo que en este tipo de + # cliente va dentro de cada mes (exportados, portadas…); la música o el stock no van por fecha. + if self.abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in self.por_mes) \ + and self.est.fechas_dentro(e.path) and (montada or cub is not None or not self.est.es_rodaje(e.path)): + self.abrir(e) return - generico = nn in self.dicc + generico = nn in self.dicc and not montada limpio = limpiar_nombre(nombre) p = R.detectar_pieza(nombre) if p and cub in ("CAMPANA", "PIEZA", None): @@ -986,19 +1344,22 @@ class Plan: 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): + if not montada and self.est.es_rodaje(e.path): 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) + gemela = self.pieza_en(mes_destino or padre, limpio, salvo=e.path) + if gemela: + self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar", + f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media") + return + num["max"] += 1 + canon = R.nombre_pieza(num["max"], 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" + motivo = montada or "no reconocida: se propone como pieza por la fecha de sus archivos" fconf = "baja" sub = limpio if con_fecha and not (anio and mes): @@ -1011,8 +1372,13 @@ class Plan: 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) + destino = self.abs_(destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path))) + if cub == "PIEZA": + gemela = self.pieza_en(os.path.dirname(destino), limpio, salvo=e.path) + if gemela: + destino = gemela + motivo += f" · la misma pieza ya está en ese mes: se junta con «{os.path.basename(gemela)}»" + self.agregar(e.path, destino, cub, "mover carpeta", motivo, conf) def mes_real(self, ruta): """((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de @@ -1036,32 +1402,58 @@ class Plan: """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") + # solo los nombres de fábrica («Grabación», «URSA») son genéricos: «Videos Maquinas», aunque + # alguien haya confirmado que es una grabación, sigue siendo el nombre de esa sesión + generico = nn in R.DICCIONARIO_SEMILLA or nn in ("grabacion", "grabado", "grabaciones") filas = archivos_bajo(self.con, e.path) if not filas: return + etiqueta_base = "" if generico else R.etiqueta_grabacion(nombre) + # Subcarpetas que no son rodaje (los exportados de esa sesión, su música, un proyecto): cada una + # va a lo suyo en vez de acabar revuelta en 00_Grabacion\OTROS. + aparte = [] + subdirs, _ = self.entradas(e.path) + for d in subdirs: + cub_d, _m = R.cubeta_carpeta(d.name, self.dicc) + if cub_d in R.PARTES_PIEZA and self.est.utiles(d.path): + self.plan_subdir(d, "contenedor", anio, mes, e.path, {"max": 0}) + aparte.append(os.path.normcase(d.path) + os.sep) grupos = defaultdict(list) desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta + aqui = os.path.normcase(os.path.normpath(e.path)) for f in filas: if f["es_basura"] or f["es_proxy"]: continue + if aparte and (os.path.normcase(f["dir"]) + os.sep).startswith(tuple(aparte)): + continue + if f["rol"] not in R.ROLES_RODAJE: + # un proyecto, un guion o una imagen de IA que estaban de paso: van donde iría cualquier + # archivo suelto de su clase, con el mes de esta carpeta + self.plan_fila(f, "contenedor", anio, mes) + 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): + # lo que digan las carpetas intermedias: el año o el mes («Blackmagic (BRAW)/2023/…»), el + # nombre de la sesión («Grabaciones/Tomas consultorio/…») y si ya estaba apartado por repetido + anio_ruta, mes_ruta, etiqueta, repetido = None, None, etiqueta_base, False + segs = [] if os.path.normcase(os.path.normpath(f["dir"])) == aqui else os.path.relpath(f["dir"], e.path).split(os.sep) + for seg in segs: if R.detectar_anio(seg): anio_ruta = R.detectar_anio(seg) elif R.detectar_mes(seg)[0]: mes_ruta = R.detectar_mes(seg)[0] + elif R.norm(seg) == R.norm(R.DUPLICADOS): + repetido = True + elif not etiqueta and R.norm(seg) not in R.DICCIONARIO_SEMILLA and not R.es_carpeta_de_tarjeta(seg): + etiqueta = R.etiqueta_grabacion(seg) + fa, fm = int(f["fecha"][:4]), int(f["fecha"][5:7]) + por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado» if anio and mes: # La carpeta dice un mes y el archivo puede decir otro. Medido el 16-sep-2026 sobre el # material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes que @@ -1069,18 +1461,17 @@ class Plan: # # Manda el archivo, no la carpeta: el nombre lo pone la cámara y la fecha de creación la # pone el disco, mientras que el nombre de la carpeta lo escribió una persona con prisa. - # Así no hay que preguntarle a nadie cuál es el mes bueno. + # Así no hay que preguntarle a nadie cuál es el mes bueno. Y el año también es el del + # archivo: antes se deducía del año de la carpeta, y unos clips de diciembre de 2025 + # metidos en «2025\03- Marzo» salían hacia diciembre de 2024. ay, am, fconf = anio, mes, "alta" if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes): - ra, rm = int(f["fecha"][:4]), int(f["fecha"][5:7]) - if f["fecha_origen"] == "nombre+mtime": - ra = anio if rm <= mes else anio - 1 # el nombre de la cámara no trae año - ay, am, fconf = ra, rm, "media" - desacuerdo[(ra, rm)] += 1 + ay, am, fconf = fa, fm, "media" + desacuerdo[(fa, fm)] += 1 else: - ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) - por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado» - fconf = "media" if por_nombre else "baja" + ay, am = fa, fm + # la fecha de un original de cámara es la del rodaje aunque no esté en el nombre + fconf = "media" if por_nombre or _fecha_de_camara(f) 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: @@ -1088,14 +1479,13 @@ class Plan: fconf = "media" if anio and not por_nombre: ay = anio - if mes_ruta: + if mes_ruta and not (por_nombre and _discrepa_mes(f["fecha"], f["fecha_origen"], anio_ruta or anio or fa, mes_ruta)): am = mes_ruta fconf = "media" hoy = datetime.date.today() if (ay, am) > (hoy.year, hoy.month): ay -= 1 - grupos[(ay, am, self.dia_de(f["fecha"], ay, am), fuente, fconf)].append((f["ruta"], f["bytes"])) - etiqueta = "" if generico else R.etiqueta_grabacion(nombre) + grupos[(ay, am, self.dia_de(f["fecha"], ay, am), etiqueta, fuente, repetido, fconf)].append((f["ruta"], f["bytes"])) aviso_fecha = "" # hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar # toda la carpeta sin marcar. @@ -1107,8 +1497,8 @@ class Plan: self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de " f"sus archivos se grabaron en {R.nombre_mes(dm)} de {da}. Se archivan en el mes en que " f"se grabaron, no en el que dice la carpeta.") - for (ay, am, dia, fuente, fconf), archivos in grupos.items(): - sub = fuente if not etiqueta else os.path.join(etiqueta, fuente) + for (ay, am, dia, etiqueta, fuente, repetido, fconf), archivos in grupos.items(): + sub = os.path.join(*[x for x in (etiqueta, fuente, R.DUPLICADOS if repetido else "") if x]) destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=dia, sub=sub, nombre=limpiar_nombre(nombre)) # si todos ya están en esa carpeta, no hay nada que hacer @@ -1122,8 +1512,11 @@ class Plan: 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: + self.plan_fila(fila, nivel, anio, mes) + + def plan_fila(self, fila, nivel, anio, mes): + """Un archivo suelto: a dónde va por lo que es.""" if fila["es_basura"] or fila["es_proxy"]: return # los recoge plan_basura if nivel == "pieza": @@ -1140,14 +1533,12 @@ class Plan: # carpeta de grabación: si no, un clip de diciembre traspapelado en la carpeta de marzo se # quedaba archivado en marzo, que es justo lo que 2.2.0 dejó de hacer con las grabaciones. if _discrepa_mes(fila["fecha"], fila["fecha_origen"], anio or fa, mes): - if fila["fecha_origen"] == "nombre+mtime" and anio: - fa = anio if fm <= mes else anio - 1 # el nombre de la cámara no trae año conf = "media" motivo += (f" · la carpeta dice {R.nombre_mes(mes)} y el archivo se grabó en " f"{R.nombre_mes(fm)}: manda el archivo") else: fm = mes - elif fila["fecha_origen"] == "mtime": + elif fila["fecha_origen"] == "mtime" and not _fecha_de_camara(fila): conf = "baja" motivo += " · mes por fecha de modificación" hoy = datetime.date.today() @@ -1186,8 +1577,70 @@ class Plan: dia=self.dia_de(fila["fecha"], fa, fm) if cub in ("GRABACION", "CELULAR") else "") self.suelto(fila, cub, destino, motivo, conf) + def plan_auditoria(self): + """Lo que ya está en su carpeta de grabación, ¿está en el mes en que se grabó? + + Las carpetas de la estructura no se vuelven a ordenar, así que lo que una versión anterior (o una + persona) dejó en el mes equivocado se quedaba ahí para siempre. Medido el 4-oct-2026 sobre el + material real: 160 clips de diciembre de 2025 (429 GB) vivían en la 00_Grabacion de marzo de 2025, y + había material de marzo en octubre, de junio en septiembre y de septiembre en noviembre. Aquí se + repasa solo lo que tiene la fecha en el nombre, que es lo que no admite discusión.""" + pat = self.patron_grab + if not pat: + return + ia, im = pat.index("{anio}"), pat.index("{mes}") + roles = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video", "foto_camara") + por_mes = defaultdict(list) # (año, mes) de la carpeta -> [(archivo, lo que cuelga por debajo)] + for f in self.con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN (%s)" + % ",".join("?" * len(roles)), (self.raiz,) + roles).fetchall(): + rel = os.path.relpath(f["dir"], self.raiz) + segs = [] if rel == "." else rel.split(os.sep) + if len(segs) < len(pat): + continue + for s, p in zip(segs, pat): + if not (R.detectar_anio(s) if p == "{anio}" else R.detectar_mes(s)[0] if p == "{mes}" else R.norm(s) == R.norm(p)): + break + else: + resto = segs[len(pat):] + if resto and _RX_DIA.match(resto[0]): + resto = resto[1:] # la carpeta del día se vuelve a calcular en destino + por_mes[(R.detectar_anio(segs[ia]), R.detectar_mes(segs[im])[0])].append((f, resto)) + grupos = defaultdict(list) + for (anio_c, mes_c), fs in por_mes.items(): + fuera = [(f, resto) for f, resto in fs if _discrepa_mes(f["fecha"], f["fecha_origen"], anio_c, mes_c)] + if not fuera: + continue + # lo que solo tiene fecha de modificación pero es del mismo día que lo que se va (la Sony que + # rodó junto a la URSA) se va con ello: si no, el rodaje quedaría partido entre dos meses + dias = {f["fecha"] for f, _ in fuera} + fuera += [(f, resto) for f, resto in fs + if f["fecha_origen"] == "mtime" and f["fecha"] in dias and _fecha_de_camara(f)] + for f, resto in fuera: + ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) + destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, + dia=self.dia_de(f["fecha"], ay, am), sub=os.sep.join(resto)) + grupos[(f["dir"], destino, anio_c, mes_c, ay, am)].append((f["ruta"], f["bytes"], f["fecha_origen"] == "nombre")) + n = b = 0 + for (origen, destino, anio_c, mes_c, ay, am), archivos in grupos.items(): + n += len(archivos) + b += sum(a[1] for a in archivos) + self.agregar(origen, self.abs_(destino), "GRABACION", "mover archivos", + f"ya estaba ordenado, pero en {R.nombre_mes(mes_c)} de {anio_c}: se grabó en {R.nombre_mes(am)} de {ay}", + "alta" if all(a[2] for a in archivos) else "media", + archivos=[(a[0], a[1]) for a in archivos]) + if n: + self.avisos.append(f"{_plural(n, 'clip ya ordenado estaba', 'clips ya ordenados estaban')} en un mes que no es el " + f"de su rodaje ({_gb(b)}). El plan los lleva al mes en que se grabaron, que es lo que dice " + f"el nombre que les puso la cámara.") + def plan_basura(self): con = self.con + # «_Papelera» era el nombre de antes. Las carpetas ordenadas con versiones viejas tienen las dos, y + # lo apartado quedaba repartido entre una y otra: se junta todo en la de ahora. + vieja = os.path.join(self.raiz, R.PAPELERAS[1]) + if os.path.isdir(vieja): + self.agregar(vieja, self.abs_(R.PAPELERA), "BASURA", "papelera", + f"la papelera con el nombre antiguo se junta con «{R.PAPELERA}»: era lo mismo en dos sitios", "alta") # 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() @@ -1625,48 +2078,108 @@ def deshacer(lote): # ========================================================================== def vocabulario(raiz): + """Las carpetas sobre las que hay algo que decidir, con lo que la app cree que es cada una. + + Recorre el inventario igual que el plan recorre el disco y se queda solo con lo que el plan llega a + ver: no entra en las carpetas de la propia app (_Para borrar, 00_Grabacion…), ni dentro de las piezas, + ni en lo que ya sabe ordenar solo (una carpeta llena de clips de cámara, un cajón con meses dentro). + + Hasta la 3.1.1 salían todas las carpetas del inventario, y eso preguntaba cosas que no cambiaban nada + o que no eran del cliente. Medido el 4-oct-2026 sobre el material real: de 6 preguntas, 6 sobraban + —«basura» era la _Para borrar\\basura de la app, «Orden» sus propias etiquetas dentro de 00_Grabacion, + y el resto carpetas que solo existían ya dentro de la papelera—, y contestarlas enseñó al diccionario + que «orden», «videos» y «basura» eran grabaciones.""" + raiz = os.path.normpath(raiz) + tipo = (raiz_cfg(raiz) or {}).get("tipo", "contenido") + dicc = DICC["carpetas"] con = db() - filas = con.execute("SELECT * FROM carpetas WHERE raiz = ?", (raiz,)).fetchall() + est = Estructura(con, raiz, dicc) + resumen = _resumen_carpetas(con, raiz) con.close() + primeros, en_mes = _propias(tipo) + pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]) + por_mes = {c for c, p in pl.items() if "{mes}" in p} + abre_cajones = "{mes}" in (pl.get("PIEZA") or "") 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) + + def apunta(ruta, titulo, cub, motivo, nota=""): + tok = R.norm(titulo) 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))) + return + c = est.carp.get(os.path.normcase(ruta)) 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 = tokens[tok] = {"token": tok, "ejemplo": os.path.basename(ruta), "n": 0, "archivos": 0, "bytes": 0, + "rutas": [], "dentro_pieza": False, "cubeta": cub or "", "motivo": motivo, "nota": nota, + "aprendido": tok in dicc and tok not in R.DICCIONARIO_SEMILLA} t["n"] += 1 - t["archivos"] += c["n"] or 0 - t["bytes"] += c["bytes"] or 0 + t["archivos"] += (c["n"] if c else 0) or 0 + t["bytes"] += (c["bytes"] if c else 0) or 0 if len(t["rutas"]) < 4: - t["rutas"].append(rel) - if tokens and any(not t["cubeta"] for t in tokens.values()): - con = db() - resumen = _resumen_carpetas(con, raiz) - con.close() - for t in tokens.values(): - if not t["cubeta"]: - _sugerir(resumen, raiz, t) - out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) - return out + t["rutas"].append(os.path.relpath(ruta, raiz)) + + def baja(ruta, nivel, anio): + for h in sorted(est.hijos(ruta), key=str.lower): + nombre = os.path.basename(h) + if _es_propia(nombre, nivel, primeros, en_mes) or R.es_basura_dir(nombre) or R.es_proxy_dir(nombre): + continue + if not est.utiles(h): + continue # vacía: nada que decidir (se ofrece en «Carpetas vacías») + a = R.detectar_anio(nombre) + if a and nivel in ("raiz", "contenedor"): + baja(h, "anio" if nivel == "raiz" else "contenedor", a) + continue + if nivel == "anio" and R.detectar_mes(nombre)[0]: + baja(h, "mes", anio) + continue + if nivel in ("raiz", "contenedor") and R.detectar_mes(re.sub(r"20\d\d", " ", nombre).strip(" -_"))[0]: + baja(h, "mes", anio) + continue + if nivel == "mes": + p = R.detectar_pieza(nombre) + if p: + cub_t, mot = R.cubeta_carpeta(p[1], dicc) + if cub_t == "GRABACION" and est.es_rodaje(h, por_nombre=_de_fabrica(p[1], "GRABACION")): + continue # la sesión del mes: el plan la reparte sola + if cub_t in ("GRABACION", "CONTENEDOR") and est.fechas_dentro(h): + apunta(h, p[1], "CONTENEDOR", mot) + baja(h, "contenedor", None) + elif cub_t != "PIEZA" and est.fechas_dentro(h): + apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas") + continue # una pieza: no se toca por dentro + cub, mot = R.cubeta_carpeta(nombre, dicc) + if cub == "IGNORAR": + apunta(h, nombre, cub, mot) + continue + if cub == "CONTENEDOR": + apunta(h, nombre, cub, mot) + m_anio = re.search(r"(20\d\d)", nombre) + baja(h, "contenedor", int(m_anio.group(1)) if m_anio else (None if est.fechas_dentro(h) else anio)) + continue + montada = False + if cub == "GRABACION": + if est.es_rodaje(h, por_nombre=_de_fabrica(nombre, "GRABACION")): + apunta(h, nombre, cub, mot) + continue + cub, montada = None, True + if abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in por_mes) and est.fechas_dentro(h) \ + and (montada or cub is not None or not est.es_rodaje(h)): + baja(h, "contenedor", None) # un cajón con meses dentro: el plan lo abre solo + continue + if cub is None: + if not montada and est.es_rodaje(h): + continue # llena de clips de cámara: el plan la reparte sola + if nivel == "mes": + continue # dentro de un mes, lo que no se reconoce es una pieza + apunta(h, nombre, "", "", "tiene nombre de grabación, pero no es una tarjeta volcada" if montada else "") + else: + apunta(h, nombre, cub, mot) + + baja(raiz, "raiz", None) + for t in tokens.values(): + if not t["cubeta"]: + _sugerir(est, resumen, raiz, t) + return sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) _ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición / diseño", "audio": "audio", @@ -1719,42 +2232,67 @@ def _resumen_carpetas(con, raiz): return agg -def _sugerir(resumen, raiz, t): - """Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre. - Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible).""" +_PARTE_FRASE = {"EXPORTADOS": "exportados", "PORTADAS": "portada", "STOCK": "stock", "IA": "IA", "GUION": "guion", + "PROYECTOS": "proyectos", "MUSICA": "música", "SFX": "sonidos"} + + +def _sugerir(est, resumen, raiz, t): + """Qué es probablemente esta carpeta: por su forma, por lo que tiene dentro y por su nombre. + Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible). + + Lo de dentro se cuenta por número de archivos, no por peso: los clips de cámara pesan cien veces más + que todo lo demás, así que por peso casi cualquier carpeta grande «era» una grabación, y aceptar esa + propuesta la mandaba a desarmar. Una grabación de verdad el plan ya la reconoce solo; aquí nunca se + propone.""" import difflib + rutas = [os.path.join(raiz, rel) for rel in t["rutas"][:3]] conteo = defaultdict(lambda: [0, 0]) total = [0, 0] - for rel in t["rutas"][:3]: - for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items(): + for r in rutas: + for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(r)), {}).items(): conteo[k][0] += n conteo[k][1] += b total[0] += n total[1] += b - orden = sorted(conteo.items(), key=lambda kv: -kv[1][1]) + orden = sorted(conteo.items(), key=lambda kv: -kv[1][0]) t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas" sug, conf, por_que = "", "", "" - # a) por el nombre: se parece a una palabra del diccionario - dicc = DICC["carpetas"] - cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82) - if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR"): - sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»" - # b) por el contenido (manda si es contundente) - if total[0] >= 3 and orden: - k, (n, b) = orden[0] - frac = b / total[1] if total[1] else n / total[0] - if frac >= 0.75: - por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS", - "documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k) - if por_cont: - if sug and sug != por_cont: - por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}" - conf = "baja" - else: - # «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos - # los clientes: exige que sea contundente y que haya varias carpetas iguales. - seguro = frac >= 0.92 and n >= 8 and total[0] >= 10 - sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)" + fechas = list(dict.fromkeys(os.path.basename(d) for r in rutas for d in est.fechas_dentro(r))) + partes = set() + for r in rutas: + partes |= est.partes(r) + if fechas: + # a) por la forma: guarda meses o años dentro + sug, conf = "CONTENEDOR", "alta" if len(fechas) >= 2 else "media" + por_que = ("dentro guarda carpetas de otras fechas (" + ", ".join(f"«{x}»" for x in fechas[:3]) + + ("…" if len(fechas) > 3 else "") + "): es un cajón que hay que abrir, no una cosa que mover entera") + elif len(partes) >= 2: + # b) por la forma: trae las carpetas de una pieza montada + sug, conf = "PIEZA", "alta" if len(partes) >= 3 else "media" + por_que = ("dentro tiene carpetas de " + ", ".join(sorted(_PARTE_FRASE.get(p, p.lower()) for p in partes)) + + ": es una pieza montada, se mueve entera") + else: + # c) por el nombre: se parece a una palabra del diccionario + dicc = DICC["carpetas"] + cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82) + if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR", "GRABACION"): + sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»" + # d) por el contenido (manda si es contundente) + if total[0] >= 3 and orden: + k, (n, b) = orden[0] + frac = n / total[0] + if frac >= 0.75: + por_cont = {"proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS", + "documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k) + if por_cont: + if sug and sug != por_cont: + por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}" + conf = "baja" + else: + # «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos + # los clientes: exige que sea contundente y que haya bastantes archivos. + seguro = frac >= 0.92 and n >= 8 and total[0] >= 10 + sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)" t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que @@ -2024,12 +2562,19 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h en_cola = {r["ruta"]: r["estado"] for r in con.execute( "SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (formato,)).fetchall()} + # lo que ya salió del convertidor no vuelve a entrar. Medido el 4-oct-2026 sobre el material real: 22 + # clips estaban convertidos dos veces y algunos tres («…_10bit_10bit_10bit.mp4»), cada pasada con su + # pérdida y sus horas, porque una conversión con buen bitrate no pasaba por «ya está en HEVC eficiente». + salidas = {os.path.normcase(os.path.normpath(r["salida"])) for r in con.execute( + "SELECT salida FROM trabajos WHERE estado IN ('listo','verificado','reemplazado') AND salida IS NOT NULL").fetchall()} con.close() out = [] for f in filas: motivo_no = "" motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" - if f["es_stock"]: + if R.es_conversion(f["nombre"]) or os.path.normcase(os.path.normpath(f["ruta"])) in salidas: + motivo_no = "ya es una conversión hecha con este programa" + elif 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)" @@ -2521,7 +3066,9 @@ def stock_viejo(raiz, meses, tipos): 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): + # la licencia, el «leeme» y el .zip que vienen dentro de un paquete de Envato se van con él: si + # no, la lista de acciones seguía contando stock viejo que ningún botón llegaba a apartar + if t not in tipos and not (t in ("Otros", "Documentos") and tipos): continue if not es_viejo(f, tope): continue @@ -3573,15 +4120,29 @@ def acciones(raiz): ["ruta NOT LIKE ? ESCAPE '!'"] * len(R.PAPELERAS)) args_pap = [esc_like(os.path.join(raiz, pp)) + "%" for pp in R.PAPELERAS] + # --- 0. el inventario es de una versión que reconocía peor las cámaras y las fechas --- + if (esc["reglas"] or 0) < R.VERSION_REGLAS: + añadir(clave="escanear", titulo="Escanear otra vez", boton="Escanear", + que="Esta versión reconoce mejor las cámaras, los clips ya convertidos y el año de cada rodaje.", + cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.", + ir="escanear") + # --- 1. basura y proxies que siguen fuera de la papelera --- fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? " "AND (es_basura = 1 OR es_proxy = 1)" + en_papelera, [raiz] + args_pap).fetchone() + vieja = con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.join(raiz, R.PAPELERAS[1]),)).fetchone() if fila["n"]: añadir(clave="limpiar", titulo="Apartar basura y proxies", boton="Ver y apartar", que="Archivos de sistema y proxies, que Resolve y Premiere regeneran solos.", cifra=f"{_plural(fila['n'], 'archivo', 'archivos')} · {_gb(fila['b'])}", detalle=f"Van a «{R.PAPELERA}» dentro de la misma carpeta. No se borra nada.", ir="plan", paso="limpiar") + elif vieja: + añadir(clave="limpiar", titulo="Juntar las dos papeleras", boton="Ver y juntar", + que=f"Lo apartado está repartido entre «{R.PAPELERAS[1]}», el nombre de antes, y «{R.PAPELERA}».", + cifra=f"{_plural(vieja['n'] or 0, 'archivo', 'archivos')} · {_gb(vieja['bytes'] or 0)}", + detalle=f"Todo pasa a «{R.PAPELERA}». No se borra nada.", + ir="plan", paso="limpiar") else: añadir(clave="limpiar", titulo="Basura y proxies", estado="hecho", que="No queda nada suelto que apartar.", cifra="", detalle="") @@ -3614,7 +4175,14 @@ def acciones(raiz): if plan: fs = [f for f in plan["filas"] if f.get("paso") == clave] marc = [f for f in fs if f.get("marcado")] - if fs: + if fs and not marc: + # solo queda lo que la app no mueve sola: no es una tarea pendiente, es algo que mirar. + # Si contara como pendiente, la carpeta no llegaría nunca a estar «al día». + añadir(clave=clave, titulo=titulo, boton="Ver", que=que, estado="espera", + cifra=_plural(len(fs), "dudoso", "dudosos"), + detalle="Lo seguro ya está hecho. Queda lo que la app no se atreve a mover sola: " + "míralo y marca lo que quieras.", ir="plan", paso=clave) + elif fs: añadir(clave=clave, titulo=titulo, boton="Ver y aplicar", que=que, cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}", detalle=(f"{len(marc)} están marcados; el resto son dudosos y no se mueven salvo que los marques." @@ -3669,10 +4237,13 @@ def acciones(raiz): # --- 7. convertir clips viejos --- o = CONFIG["optimizar"] - cam = con.execute( - "SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b, SUM(analizado = 1) ana FROM archivos WHERE raiz = ? " + # los clips que ya salieron del convertidor siguen siendo de cámara, pero no están por aligerar + por_aligerar = [f for f in con.execute( + "SELECT nombre, bytes, analizado FROM archivos WHERE raiz = ? " "AND rol IN ('camara_raw','camara_video') AND es_proxy = 0 AND es_basura = 0 AND bytes >= ?" + en_papelera, - [raiz, int(o.get("min_mb", 300)) * 1024 * 1024] + args_pap).fetchone() + [raiz, int(o.get("min_mb", 300)) * 1024 * 1024] + args_pap).fetchall() if not R.es_conversion(f["nombre"])] + cam = {"n": len(por_aligerar), "b": sum(f["bytes"] or 0 for f in por_aligerar), + "ana": sum(1 for f in por_aligerar if f["analizado"] == 1)} hechos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado = 'reemplazado'", (raiz.lower(),)).fetchone()["n"] if cam["n"]: @@ -3701,6 +4272,8 @@ def acciones(raiz): con.close() # «lo seguro» son las acciones que se pueden hacer sin elegir nada: no entran convertir ni respaldar auto = [a["clave"] for a in out if a["estado"] == "toca" and a["clave"] in ("limpiar", "fechas", "resto", "stock", "repetidos")] + if (esc["reglas"] or 0) < R.VERSION_REGLAS: + auto = [] # con un inventario viejo no se hace nada «de una vez»: primero se relee return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc), "automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None} @@ -3984,6 +4557,7 @@ class Manejador(BaseHTTPRequestHandler): CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]}) if isinstance(d.get("actualizaciones"), dict) and "auto" in d["actualizaciones"]: CONFIG["actualizaciones"]["elegido"] = True # lo decidió una persona, no un valor por defecto + _sufijos_conversion() guardar_json(CONFIG_PATH, CONFIG) return self._json({"ok": True, "config": CONFIG}) if ruta == "/api/diccionario": @@ -3991,6 +4565,8 @@ class Manejador(BaseHTTPRequestHandler): cub = d.get("cubeta") if not tok: return self._json({"error": "token vacío"}, 400) + if cub and tok in R.NOMBRES_DE_LA_APP: + return self._json({"error": f"«{d.get('token')}» es una carpeta de la propia app: no hay nada que enseñarle"}, 400) with LOCK: if cub in (None, ""): DICC["carpetas"].pop(tok, None) @@ -4046,6 +4622,8 @@ class Manejador(BaseHTTPRequestHandler): raiz = os.path.normpath(d["raiz"]) if asistente.ESTADO["activo"]: return self._json({"error": "ya hay algo en marcha en esta carpeta"}, 409) + if ((acciones(raiz).get("escaneo") or {}).get("reglas") or 0) < R.VERSION_REGLAS: + return self._json({"error": "el inventario de esta carpeta es de una versión anterior: escanéala otra vez antes"}, 409) diag = asistente.diagnostico(raiz) filas = Plan(raiz_cfg(raiz)).correr()["filas"] dec = {p["clave"]: p["defecto"] for p in asistente.pasos(diag, filas)} diff --git a/prueba_funcional.py b/prueba_funcional.py index c9485d6..568cb06 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -189,7 +189,10 @@ def p_nombrar(): v2 = get('/api/vocabulario' + q(raiz=RAIZ)) assert {t['token']: t for t in v2['tokens']}['raro'].get('cubeta') == 'DOCUMENTOS' c, r = post('/api/diccionario', {'token': 'raro', 'cubeta': None}) # y se olvida - return 'reconoce, aprende y olvida un nombre de carpeta' + # las carpetas de la propia app no se enseñan: «basura» es su _Para borrar\basura, no una del cliente + c, r = post('/api/diccionario', {'token': 'Basura', 'cubeta': 'GRABACION'}) + assert c == 400 and 'propia app' in r.get('error', ''), (c, r) + return 'reconoce, aprende y olvida un nombre de carpeta; no deja enseñar las de la app' PLAN = {} diff --git a/prueba_orden.py b/prueba_orden.py new file mode 100644 index 0000000..344ed3b --- /dev/null +++ b/prueba_orden.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +"""Pruebas del motor de orden. Cada caso reproduce en pequeño algo medido en una carpeta de verdad (la +de Andrea Galeano, el 4-oct-2026): monta un cliente de ensayo en una carpeta temporal, lo escanea, pide +el plan y, donde importa, lo aplica y mira cómo queda el disco. + + py -3.12 prueba_orden.py +""" +import os +import sys +import json +import time +import shutil +import tempfile +import unittest + +AQUI = os.path.dirname(os.path.abspath(__file__)) +TMP = tempfile.mkdtemp(prefix="organilab-motor-") +APP = os.path.join(TMP, "app") +os.makedirs(os.path.join(APP, "datos")) +for _f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "firma.py", "instalar.py", "ventana.py", + "resolve_bridge.py"): + shutil.copy(os.path.join(AQUI, _f), APP) +# un diccionario como el que dejaba la 3.1.1: con una carpeta de la propia app aprendida como grabación +with open(os.path.join(APP, "datos", "diccionario.json"), "w", encoding="utf-8") as _fh: + json.dump({"carpetas": {"basura": "GRABACION", "mi carpeta rara": "FOTOS"}, "reglas": [], "aprendido": []}, _fh) +sys.path.insert(0, APP) +import organizador as O # noqa: E402 (tiene que importarse desde la copia: sus datos van junto a él) +import asistente # noqa: E402 +R = O.R +S = os.sep + + +def cliente(nombre, tipo="contenido"): + raiz = os.path.join(TMP, nombre) + os.makedirs(raiz) + O.CONFIG["raices"].append({"ruta": raiz, "cliente": nombre, "tipo": tipo}) + return raiz + + +def mk(raiz, rel, y, m, d, tam=2048): + """Crea un archivo con esa fecha de modificación (a mediodía).""" + p = os.path.join(raiz, rel.replace("/", S)) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as fh: + fh.write(os.urandom(tam)) + t = time.mktime(time.struct_time((y, m, d, 12, 0, 0, 0, 0, -1))) + os.utime(p, (t, t)) + return p + + +def plan(raiz): + O.escanear(raiz) + prog = O.PROGRESO[os.path.normpath(raiz)] + assert prog["estado"] == "listo", prog + return O.Plan(O.raiz_cfg(raiz)).correr() + + +def aplicar(raiz, p): + r = O.aplicar(raiz, [f for f in p["filas"] if f["marcado"]]) + assert not r["errores"], r["errores"] + return r + + +def arbol(raiz): + return sorted(os.path.relpath(os.path.join(b, f), raiz) for b, _, fs in os.walk(raiz) for f in fs) + + +def nombres(raiz): + return sorted(os.path.basename(x) for x in arbol(raiz)) + + +def rel(*partes): + return S.join(partes) + + +class Reglas(unittest.TestCase): + def test_un_clip_convertido_sigue_siendo_de_su_camara(self): + """166 clips ya convertidos habían perdido su cámara: pasaban por exportados.""" + self.assertEqual(R.firma_archivo("C0001_10bit.mp4"), "SONY") + self.assertEqual(R.firma_archivo("20251111_Labstream5320_10bit.mp4"), "SONY") + self.assertEqual(R.firma_archivo("A002_03110416_C042_10bit_10bit.mp4"), "BM_PROXY") + self.assertEqual(R.firma_archivo("A006_06010242_C114 (2)_10bit.mp4"), "BM_PROXY") + self.assertIsNone(R.firma_archivo("INMODE_10bit.mp4")) # no era de cámara: sigue sin serlo + self.assertEqual(R.rol_archivo("C0001_10bit.mp4", ".mp4", R.firma_archivo("C0001_10bit.mp4")), "camara_video") + self.assertTrue(R.es_conversion("C0001_10bit.mp4")) + self.assertTrue(R.es_conversion("C0001_10bit_10bit.mp4")) + self.assertFalse(R.es_conversion("C0001.MP4")) + self.assertEqual(R.sin_conversion("C0001_10bit_10bit.mp4"), "C0001.mp4") + self.assertEqual(R.raiz_derivado("C0001_10bit_10bit.mp4"), "C0001") + self.assertIsNone(R.raiz_derivado("C0001.MP4")) + + def test_lo_aprendido_vale_por_el_nombre_entero(self): + """Confirmar que «Videos» era una grabación convertía en grabación a «videos ia» y a «01- Orden».""" + d = dict(R.DICCIONARIO_SEMILLA, videos="GRABACION", orden="GRABACION") + self.assertEqual(R.cubeta_carpeta("Videos", d)[0], "GRABACION") + self.assertEqual(R.cubeta_carpeta("videos ia", d)[0], "IA") # la palabra de fábrica sí vale + self.assertIsNone(R.cubeta_carpeta("01- Orden", d)[0]) + self.assertIsNone(R.cubeta_carpeta("Orden nuevo de cosas", d)[0]) + self.assertEqual(R.cubeta_carpeta("04_Revisar-Borrar", R.DICCIONARIO_SEMILLA)[0], "REVISAR") + + def test_fecha_de_blackmagic(self): + """El nombre trae mes y día. Si el archivo no se ha tocado desde el rodaje, el año es seguro.""" + intacto = time.mktime(time.struct_time((2025, 12, 9, 18, 30, 0, 0, 0, -1))) + reescrito = time.mktime(time.struct_time((2026, 7, 1, 10, 0, 0, 0, 0, -1))) + self.assertEqual(R.fecha_de_nombre("A014_12091814_C001.braw", intacto), ("2025-12-09", "nombre")) + self.assertEqual(R.fecha_de_nombre("A014_12091814_C001.mp4", reescrito), ("2025-12-09", "nombre+mtime")) + self.assertEqual(R.clave_rodaje("A014_12091814_C001_10bit.mp4"), "A014_1209") + self.assertIsNone(R.clave_rodaje("C0001.MP4")) + + def test_carpetas_de_tarjeta(self): + for n in ("DCIM", "100MEDIA", "PRIVATE", "M4ROOT", "CLIP", "A001_0305XY", "100MSDCF"): + self.assertTrue(R.es_carpeta_de_tarjeta(n), n) + for n in ("Tomas consultorio", "Entrevista", "Maquinas"): + self.assertFalse(R.es_carpeta_de_tarjeta(n), n) + + def test_rodaje_de_una_misma_tarjeta(self): + """Los clips de la misma tarjeta y día comparten año, y la madrugada cuenta como la noche anterior.""" + def fila(nombre, fecha, origen): + return ("x" + S + nombre, "x", "x", nombre, ".mp4", 1, 0.0, "camara_video", "BM_PROXY", "", None, None, + fecha, origen, 0, 0, 0) + filas = [fila("A002_03020727_C001.mp4", "2025-03-02", "nombre+mtime"), # reescrito en dic-2025 + fila("A002_03020727_C002.mp4", "2026-03-02", "nombre+mtime"), # reescrito en jul-2026 + fila("A014_12091814_C001.braw", "2025-12-09", "nombre"), # intacto + fila("A014_12091814_C001.mp4", "2025-12-09", "nombre+mtime"), + fila("A006_05312107_C001.mp4", "2025-05-31", "nombre+mtime"), # empieza de noche… + fila("A006_06010000_C002.mp4", "2025-06-01", "nombre+mtime"), # …y sigue de madrugada + fila("A003_06010400_C019.mp4", "2025-06-01", "nombre+mtime"), # otra tarjeta, misma madrugada + fila("A009_06011500_C001.mp4", "2025-06-01", "nombre+mtime")] # esa tarde: otro rodaje + O._fechas_de_rodaje(filas) + por = {f[3]: (f[12], f[13]) for f in filas} + self.assertEqual(por["A002_03020727_C002.mp4"], ("2025-03-02", "nombre+mtime")) # el año más antiguo del grupo + self.assertEqual(por["A014_12091814_C001.mp4"], ("2025-12-09", "nombre")) # el año seguro se contagia + self.assertEqual(por["A006_06010000_C002.mp4"][0], "2025-05-31") + self.assertEqual(por["A003_06010400_C019.mp4"][0], "2025-05-31") + self.assertEqual(por["A009_06011500_C001.mp4"][0], "2025-06-01") + + +class Arranque(unittest.TestCase): + def test_se_olvida_lo_que_era_de_la_app(self): + """«basura» se aprendió como grabación porque «Por nombrar» preguntaba por _Para borrar\\basura.""" + self.assertNotIn("basura", O.DICC["carpetas"]) + self.assertEqual(O.DICC["carpetas"].get("mi carpeta rara"), "FOTOS") # lo del cliente se conserva + with open(O.DICC_PATH, encoding="utf-8") as fh: + self.assertNotIn("basura", json.load(fh)["carpetas"]) + + +class Motor(unittest.TestCase): + def aprende(self, token, cubeta): + O.DICC["carpetas"][token] = cubeta + self.addCleanup(lambda: O.DICC["carpetas"].pop(token, None)) + + def test_un_cajon_no_se_desarma(self): + """«Orden» (una organización anterior entera) se aprendió como grabación y el plan la iba a repartir + por cámara y por mes, proyectos incluidos. Ahora se abre, y cada cosa va a su sitio.""" + raiz = cliente("Cajon") + mk(raiz, "2026/05- Mayo/03- El cuello/portada vieja.png", 2026, 5, 10) + mk(raiz, "Orden/01_Grabaciones/Tomas consultorio/20251111_Labstream5320.MP4", 2025, 11, 11) + mk(raiz, "Orden/01_Grabaciones/Tomas consultorio/20251111_Labstream5321_10bit.mp4", 2026, 1, 10) + mk(raiz, "Orden/01_Grabaciones/Tomas consultorio/Duplicados/20251111_Labstream5322.MP4", 2025, 11, 11) + mk(raiz, "Orden/01_Grabaciones/Videos Maquinas/A006_05312107_C001.mp4", 2026, 1, 10) + mk(raiz, "Orden/01_Grabaciones/Videos Maquinas/A006_06010000_C002.mp4", 2026, 1, 10) + mk(raiz, "Orden/01_Grabaciones/Videos Maquinas/A006_06010030_C003.braw", 2025, 6, 1) + mk(raiz, "Orden/02_Proyectos/Mayo/03 - El cuello/Exportado/final.mp4", 2026, 5, 20) + mk(raiz, "Orden/02_Proyectos/Mayo/03 - El cuello/Portada/p.png", 2026, 5, 20) + mk(raiz, "Orden/02_Proyectos/Mayo/03 - El cuello/proyecto.prproj", 2026, 5, 20) + mk(raiz, "Orden/03_Recursos/logo.png", 2026, 5, 20) + self.aprende("orden", "GRABACION") + antes = nombres(raiz) + p = plan(raiz) + self.assertFalse([f for f in p["filas"] if f["origen_rel"] == "Orden"], "sigue queriendo mover «Orden» entera") + self.assertTrue(any("guarda dentro carpetas de otras fechas" in a for a in p["avisos"]), p["avisos"]) + destinos = {f["destino_rel"] for f in p["filas"]} + self.assertIn(rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY"), destinos) + self.assertIn(rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY", "Duplicados"), destinos) + # el rodaje del 31 de mayo siguió de madrugada: todo a mayo de 2025, el año que da el .braw intacto + self.assertIn(rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "BLACKMAGIC_MP4"), destinos) + self.assertIn(rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "URSA_BRAW"), destinos) + self.assertFalse([d for d in destinos if "06- Junio" in d], destinos) + junta = [f for f in p["filas"] if f["accion"] == "renombrar" and f["origen_rel"].endswith("03 - El cuello")] + self.assertTrue(junta and junta[0]["marcado"] and "se junta" in junta[0]["motivo"], junta) + aplicar(raiz, p) + self.assertEqual(nombres(raiz), antes, "se perdió o se duplicó algún archivo") + for esperado in (rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY", "20251111_Labstream5320.MP4"), + rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY", "20251111_Labstream5321_10bit.mp4"), + rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "BLACKMAGIC_MP4", "A006_06010000_C002.mp4"), + rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "URSA_BRAW", "A006_06010030_C003.braw"), + rel("2026", "05- Mayo", "03- El cuello", "Exportado", "final.mp4"), + rel("2026", "05- Mayo", "03- El cuello", "proyecto.prproj"), + rel("2026", "05- Mayo", "03- El cuello", "portada vieja.png")): + self.assertTrue(os.path.isfile(os.path.join(raiz, esperado)), "falta " + esperado + "\n" + "\n".join(arbol(raiz))) + self.assertFalse(os.path.isdir(os.path.join(raiz, "2026", "05- Mayo", "03 - El cuello")), "quedó la pieza gemela") + # y con todo en su sitio, no queda nada que proponer sobre lo mismo + self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"] if f["marcado"]]) + + def test_una_pieza_no_es_una_grabacion(self): + """Una pieza numerada cuyo título alguien confirmó como «grabación» se quedaba sin sus archivos.""" + raiz = cliente("Pieza") + for n in ("final VR1.mp4", "toma 2.mp4", "Musica/tema.wav", "Portada/p.png"): + mk(raiz, "2026/02- Febrero/03- Se me cayó el parpado/" + n, 2026, 2, 10) + mk(raiz, "2026/08- Agosto/03- Grabacion Agosto/C0001.MP4", 2026, 8, 5) + self.aprende("se me cayo el parpado", "GRABACION") + p = plan(raiz) + self.assertFalse([f for f in p["filas"] if "parpado" in f["origen_rel"]], "desarma la pieza") + # la sesión del mes, con nombre de fábrica y clips de cámara, sí se reparte + self.assertIn(rel("2026", "08- Agosto", "00_Grabacion", "SONY"), {f["destino_rel"] for f in p["filas"]}) + + def test_lo_que_no_es_rodaje_no_va_a_otros(self): + """Dentro de una carpeta de grabación había proyectos e imágenes de IA que acababan en 00_Grabacion\\OTROS.""" + raiz = cliente("Mezcla") + for n in ("C0001.MP4", "C0002.MP4", "C0003.MP4"): + mk(raiz, "Grabación evento/" + n, 2026, 4, 10) + mk(raiz, "Grabación evento/guion.docx", 2026, 4, 10) + mk(raiz, "Grabación evento/proyecto.drp", 2026, 4, 10) + mk(raiz, "Grabación evento/Export/final.mp4", 2026, 4, 12) + p = plan(raiz) + por_archivo = {os.path.basename(a): f for f in p["filas"] for a in (f["archivos"] or [])} + self.assertEqual(por_archivo["C0001.MP4"]["destino_rel"], rel("2026", "04- Abril", "00_Grabacion", "evento", "SONY")) + self.assertTrue(por_archivo["C0001.MP4"]["marcado"], "un original de cámara fechado por su archivo debe ir marcado") + self.assertEqual(por_archivo["proyecto.drp"]["destino_rel"], rel("01_Proyectos", "DaVinci Resolve")) + self.assertNotIn("00_Grabacion", por_archivo["guion.docx"]["destino_rel"]) + self.assertNotIn("final.mp4", por_archivo) # va con su carpeta, no suelto + export = [f for f in p["filas"] if f["origen_rel"] == rel("Grabación evento", "Export")] + self.assertTrue(export and export[0]["destino_rel"] == rel("2026", "04- Abril", "_Exportados"), export) + self.assertFalse([f for f in p["filas"] if f["destino_rel"].endswith("OTROS")]) + + def test_repaso_de_lo_ya_ordenado(self): + """160 clips de diciembre de 2025 vivían en la 00_Grabacion de marzo, y nada los volvía a mirar.""" + raiz = cliente("Repaso") + g = "2025/03- Marzo/00_Grabacion/" + mk(raiz, g + "URSA_BRAW/A014_12091814_C001.braw", 2025, 12, 9) + mk(raiz, g + "URSA_BRAW/A014_12100130_C002.braw", 2025, 12, 10) # la misma noche, ya de madrugada + mk(raiz, g + "URSA_BRAW/Duplicados/A014_12091814_C001.mp4", 2026, 7, 1) # reescrito después + mk(raiz, g + "SONY/C0001.MP4", 2025, 12, 9) # sin fecha en el nombre, mismo día + mk(raiz, g + "SONY/C0002.MP4", 2025, 3, 5) # este sí es de marzo + mk(raiz, g + "URSA_BRAW/A002_03110342_C036.braw", 2025, 3, 11) # y este + antes = nombres(raiz) + p = plan(raiz) + filas = [f for f in p["filas"] if "ya estaba ordenado" in f["motivo"]] + movidos = sorted(os.path.basename(a) for f in filas for a in f["archivos"]) + self.assertEqual(movidos, ["A014_12091814_C001.braw", "A014_12091814_C001.mp4", "A014_12100130_C002.braw", "C0001.MP4"]) + self.assertTrue(all(f["marcado"] for f in filas), filas) + self.assertTrue(any("en un mes que no es el de su rodaje" in a for a in p["avisos"]), p["avisos"]) + aplicar(raiz, p) + self.assertEqual(nombres(raiz), antes) + dic = os.path.join(raiz, "2025", "12- Diciembre", "00_Grabacion") + for esperado in ("URSA_BRAW" + S + "A014_12091814_C001.braw", "URSA_BRAW" + S + "A014_12100130_C002.braw", + "URSA_BRAW" + S + "Duplicados" + S + "A014_12091814_C001.mp4", "SONY" + S + "C0001.MP4"): + self.assertTrue(os.path.isfile(os.path.join(dic, esperado)), "falta " + esperado + "\n" + "\n".join(arbol(raiz))) + mar = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion") + self.assertTrue(os.path.isfile(os.path.join(mar, "SONY", "C0002.MP4"))) + self.assertTrue(os.path.isfile(os.path.join(mar, "URSA_BRAW", "A002_03110342_C036.braw"))) + self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"] if f["marcado"]], "el repaso no termina") + + def test_carpetas_gemelas_y_papelera_vieja(self): + """«Exportados» y «_Exportados» convivían en el mismo mes, y lo apartado, en dos papeleras.""" + raiz = cliente("Gemelas") + mk(raiz, "2026/03- Marzo/Exportados/a.mp4", 2026, 3, 10) + mk(raiz, "2026/03- Marzo/_Exportados/b.mp4", 2026, 3, 11) + mk(raiz, "2026/05- Mayo/EXPORTADOS/c.mp4", 2026, 5, 11) + mk(raiz, "_Papelera/basura/x.tmp", 2026, 1, 1) + mk(raiz, "_Para borrar/basura/y.tmp", 2026, 1, 1) + p = plan(raiz) + gemela = [f for f in p["filas"] if f["origen_rel"] == rel("2026", "03- Marzo", "Exportados")] + self.assertTrue(gemela and gemela[0]["destino_rel"] == rel("2026", "03- Marzo", "_Exportados") and gemela[0]["marcado"], gemela) + vieja = [f for f in p["filas"] if f["origen_rel"] == "_Papelera"] + self.assertTrue(vieja and vieja[0]["destino_rel"] == "_Para borrar" and vieja[0]["paso"] == "limpiar", vieja) + self.assertEqual([a["titulo"] for a in O.acciones(raiz)["acciones"] if a["clave"] == "limpiar"], ["Juntar las dos papeleras"]) + aplicar(raiz, p) + self.assertEqual(arbol(raiz), sorted([rel("2026", "03- Marzo", "_Exportados", "a.mp4"), rel("2026", "03- Marzo", "_Exportados", "b.mp4"), + rel("2026", "05- Mayo", "_Exportados", "c.mp4"), + rel("_Para borrar", "basura", "x.tmp"), rel("_Para borrar", "basura", "y.tmp")])) + + def test_por_nombrar_solo_pregunta_lo_que_importa(self): + """De 6 preguntas, 6 sobraban: carpetas de la app, de dentro de la papelera o de dentro de una pieza.""" + raiz = cliente("Nombrar") + mk(raiz, "_Para borrar/basura/Videos/c.mp4", 2026, 1, 1) # dentro de la papelera + mk(raiz, "2026/01- Enero/00_Grabacion/Orden/SONY/C0001.MP4", 2026, 1, 5) # una etiqueta de la propia app + mk(raiz, "2026/01- Enero/01- Pieza/Rara/x.png", 2026, 1, 5) # dentro de una pieza + mk(raiz, "2026/01- Enero/Sin numero/x.png", 2026, 1, 6) # en un mes: será una pieza + for n in ("Exportado/f.mp4", "Portada/p.png", "Musica/m.wav", "r.prproj"): + mk(raiz, "Botox labios/" + n, 2026, 2, 2) # una pieza montada, suelta en la raíz + for n in ("a.xyz", "b.xyz", "c.xyz"): + mk(raiz, "Cosas/" + n, 2026, 2, 2) + for i in range(5): + mk(raiz, "Tarjeta/C%04d.MP4" % i, 2026, 2, 3) # llena de clips: se reparte sola + os.makedirs(os.path.join(raiz, "Vacia", "dentro")) + plan(raiz) + voc = {t["token"]: t for t in O.vocabulario(raiz)} + self.assertEqual(sorted(k for k, t in voc.items() if not t["cubeta"]), ["botox labios", "cosas"], sorted(voc)) + for sobra in ("videos", "orden", "rara", "sin numero", "tarjeta", "vacia", "basura", "sony"): + self.assertNotIn(sobra, voc) + t = voc["botox labios"] + self.assertEqual((t["sugerencia"], t["sug_conf"]), ("PIEZA", "alta"), t) + self.assertNotEqual(voc["cosas"].get("sugerencia"), "GRABACION") + nombrar = [a for a in O.acciones(raiz)["acciones"] if a["clave"] == "nombrar"][0] + self.assertIn("2 carpetas", nombrar["cifra"]) + + def test_piezas_gemelas_en_el_mismo_mes(self): + """La misma pieza escrita de dos formas en el mismo mes: se juntan, y solo se mueve una de las dos.""" + raiz = cliente("Gemelas de pieza") + mk(raiz, "2026/05- Mayo/03 - El cuello/a.mp4", 2026, 5, 10) + mk(raiz, "2026/05- Mayo/03- El cuello/b.mp4", 2026, 5, 10) + mk(raiz, "2026/06- Junio/Botox/c.mp4", 2026, 6, 10) + mk(raiz, "2026/06- Junio/02- Botox/d.mp4", 2026, 6, 10) + p = plan(raiz) + self.assertEqual(sorted((f["origen_rel"], f["destino_rel"], f["marcado"]) for f in p["filas"]), + [(rel("2026", "05- Mayo", "03 - El cuello"), rel("2026", "05- Mayo", "03- El cuello"), True), + (rel("2026", "06- Junio", "Botox"), rel("2026", "06- Junio", "02- Botox"), True)]) + aplicar(raiz, p) + self.assertEqual(arbol(raiz), [rel("2026", "05- Mayo", "03- El cuello", "a.mp4"), rel("2026", "05- Mayo", "03- El cuello", "b.mp4"), + rel("2026", "06- Junio", "02- Botox", "c.mp4"), rel("2026", "06- Junio", "02- Botox", "d.mp4")]) + + def test_una_campana_con_fechas_no_es_un_cajon(self): + """Abrir lo que guarda meses dentro vale donde las piezas van por mes; una campaña se mueve entera.""" + raiz = cliente("Anuncios", "pauta") + mk(raiz, "Campaña Navidad/2025/Noviembre/anuncio.mp4", 2025, 11, 20) + mk(raiz, "Campaña Navidad/2025/Noviembre/guion.docx", 2025, 11, 20) + p = plan(raiz) + self.assertFalse([a for a in p["avisos"] if "guarda dentro" in a], p["avisos"]) + self.assertEqual([(f["origen_rel"], f["accion"]) for f in p["filas"]], [("Campaña Navidad", "mover carpeta")]) + + def test_lo_dudoso_no_cuenta_como_pendiente(self): + """Con lo seguro hecho, la carpeta está al día aunque quede algo que la app no mueve sola.""" + raiz = cliente("Dudas") + mk(raiz, "2026/03- Marzo/01 - Botox/final.mp4", 2026, 3, 10) # solo falta un retoque de nombre, sin marcar + p = plan(raiz) + self.assertEqual([(f["accion"], f["marcado"]) for f in p["filas"]], [("renombrar", False)]) + O.PLANES[os.path.normpath(raiz)] = p + a = O.acciones(raiz) + fechas = [x for x in a["acciones"] if x["clave"] == "fechas"][0] + self.assertEqual((fechas["estado"], fechas["cifra"]), ("espera", "1 dudoso")) + self.assertNotIn("fechas", a["automatico"]) + + def test_hacer_lo_seguro_no_mueve_lo_dudoso(self): + filas = [{"cubeta": "GRABACION", "marcado": False, "confianza": "baja"}, + {"cubeta": "GRABACION", "marcado": True, "confianza": "media"}, + {"cubeta": "BASURA", "marcado": True, "confianza": "alta"}, + {"cubeta": "EXPORTADOS", "marcado": False, "confianza": "baja"}] + elegidas = asistente.filas_elegidas(filas, {"carpetas": "seguras"}) + self.assertEqual([(f["cubeta"], f["marcado"]) for f in elegidas], [("GRABACION", True), ("BASURA", True)]) + + def test_el_convertidor_no_repite(self): + """22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»).""" + raiz = cliente("Convertidos") + mk(raiz, "2026/04- Abril/00_Grabacion/SONY/C0001.MP4", 2025, 4, 10) + mk(raiz, "2026/04- Abril/00_Grabacion/SONY/C0002_10bit.mp4", 2025, 4, 10) + plan(raiz) + con = O.db() + with con: + con.execute("UPDATE archivos SET analizado = 1, codec = 'hevc', ancho = 1920, alto = 1080, fps = 25, dur = 10, " + "kbps = 90000 WHERE raiz = ?", (os.path.normpath(raiz),)) + roles = {r["nombre"]: r["rol"] for r in con.execute("SELECT nombre, rol FROM archivos WHERE raiz = ?", (os.path.normpath(raiz),))} + con.close() + self.assertEqual(roles, {"C0001.MP4": "camara_video", "C0002_10bit.mp4": "camara_video"}) + cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(os.path.normpath(raiz), 0, "equilibrado", 0)} + self.assertEqual(cs["C0001.MP4"], "") + self.assertIn("ya es una conversión", cs["C0002_10bit.mp4"]) + + def test_el_sufijo_del_convertidor_se_puede_cambiar(self): + """El sufijo que pone el convertidor es un ajuste: si se cambia, las reglas tienen que enterarse.""" + antes = O.CONFIG["optimizar"].get("sufijo") + self.addCleanup(lambda: O.CONFIG["optimizar"].__setitem__("sufijo", antes)) + self.assertFalse(R.es_conversion("C0001_arch.mp4")) + O.CONFIG["optimizar"]["sufijo"] = "_arch" + O._sufijos_conversion() + self.assertTrue(R.es_conversion("C0001_arch.mp4")) + self.assertEqual(R.firma_archivo("C0001_arch.mp4"), "SONY") + self.assertTrue(R.es_conversion("C0001_10bit.mp4")) # y los de siempre siguen valiendo + + def test_inventario_de_otra_version(self): + """Lo que apunta el escaneo cambió: un inventario viejo se relee antes de planear nada.""" + raiz = cliente("Viejo") + mk(raiz, "Grabacion/C0001.MP4", 2026, 4, 10) + plan(raiz) + self.assertNotEqual(O.acciones(raiz)["acciones"][0]["clave"], "escanear") + con = O.db() + with con: + con.execute("UPDATE escaneos SET reglas = 0 WHERE raiz = ?", (os.path.normpath(raiz),)) + con.close() + a = O.acciones(raiz) + self.assertEqual((a["acciones"][0]["clave"], a["acciones"][0]["estado"], a["automatico"]), ("escanear", "toca", [])) + self.assertTrue(any("versión anterior" in x for x in O.Plan(O.raiz_cfg(raiz)).correr()["avisos"])) + + +if __name__ == "__main__": + try: + unittest.main(verbosity=2, exit=False) + finally: + shutil.rmtree(TMP, ignore_errors=True) diff --git a/reglas.py b/reglas.py index c1aafd1..d59270a 100644 --- a/reglas.py +++ b/reglas.py @@ -147,10 +147,45 @@ FUENTE_CARPETA = { # nombre de subcarpeta al separar una sesión de grabación } +# Sufijos que pone el convertidor de la propia app. «C0001_10bit.mp4» sigue siendo el clip C0001 de la +# Sony: medido el 4-oct-2026 sobre el material real, 166 clips ya convertidos habían perdido su cámara +# (pasaban por «video que no viene de cámara» y el plan los mandaba a OTROS o a exportados), y 22 se +# habían convertido dos y hasta tres veces porque el convertidor no reconocía su propia salida. +SUFIJOS_CONVERSION = ("_10bit", "_ligero", "_h264", "_proxy", "_lt", "_hq") + + +def sin_conversion(nombre: str) -> str: + """«C0001_10bit_10bit.mp4» -> «C0001.mp4»: el nombre sin los sufijos del convertidor.""" + base, ext = os.path.splitext(nombre or "") + sigue = True + while sigue: + sigue = False + b = base.lower() + for suf in SUFIJOS_CONVERSION: + if suf and b.endswith(suf) and len(base) > len(suf): + base = base[:-len(suf)] + sigue = True + break + return base + ext + + +def es_conversion(nombre: str) -> bool: + """True si el archivo es salida del convertidor (lleva su sufijo): no se convierte otra vez.""" + return sin_conversion(nombre) != (nombre or "") + + +_RX_COPIA = re.compile(r"( \(\d+\)|-\d{3}| - copia)(?=\.[^.]+$)", re.I) + + def firma_archivo(nombre: str): - for k, rx, desc in FIRMAS_RE: - if rx.search(nombre): - return k + # se prueba el nombre tal cual, luego sin los sufijos del convertidor y sin el « (2)» que pone + # Windows al copiar: «A006_06010242_C114 (2)_10bit.mp4» sigue siendo un clip de la Blackmagic + limpio = sin_conversion(nombre) + limpio = _RX_COPIA.sub("", limpio) + for intento in ((nombre,) if limpio == nombre else (nombre, limpio)): + for k, rx, desc in FIRMAS_RE: + if rx.search(intento): + return k return None @@ -318,6 +353,29 @@ def base_derivado(nombre: str): return None +def raiz_derivado(nombre: str): + """El original del todo, quitando todos los sufijos: «C0001_10bit_10bit.mp4» -> «C0001». None si el + nombre no lleva ninguno.""" + base = os.path.splitext(nombre)[0] + visto = False + while True: + b = base.lower() + for suf in SUFIJOS_DERIVADO: + if b.endswith(suf) and len(base) > len(suf): + base = base[:-len(suf)] + visto = True + break + else: + break + return base if visto else None + + +def clave_rodaje(nombre: str): + """«A014_12091814_C001.braw» -> «A014_1209»: la tarjeta y el día. Todos los clips que la comparten + salieron del mismo rodaje, así que comparten año aunque a alguno se le haya cambiado la fecha.""" + return nombre[:9].upper() if _RX_BM.match(nombre or "") else None + + def fecha_real(st): """El instante más antiguo que el disco sabe de este archivo. @@ -352,7 +410,12 @@ def fecha_de_nombre(nombre: str, mtime: float): mt = datetime.datetime.fromtimestamp(mtime) anio = mt.year if (mes, dia) <= (mt.month, mt.day) else mt.year - 1 try: - return datetime.date(anio, mes, dia).isoformat(), "nombre+mtime" + d = datetime.date(anio, mes, dia) + # el nombre de Blackmagic trae mes y día, no año. Si la fecha del archivo cae ese mismo + # día, el archivo no se ha tocado desde el rodaje y el año es seguro; si cae después + # (lo reescribió una conversión o una copia), el año es solo «el más reciente posible». + intacto = abs((mt.date() - d).days) <= 1 + return d.isoformat(), "nombre" if intacto else "nombre+mtime" except ValueError: pass return datetime.date.fromtimestamp(mtime).isoformat(), "mtime" @@ -395,6 +458,7 @@ DICCIONARIO_SEMILLA = { "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", + "atomos": "GRABACION", # exportados "render": "EXPORTADOS", "renders": "EXPORTADOS", "export": "EXPORTADOS", "exportado": "EXPORTADOS", "exportados": "EXPORTADOS", "exports": "EXPORTADOS", "finales": "EXPORTADOS", "final": "EXPORTADOS", @@ -451,6 +515,7 @@ DICCIONARIO_SEMILLA = { "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", + "revisar": "REVISAR", "por revisar": "REVISAR", "borrar": "REVISAR", "eliminar": "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", @@ -470,6 +535,9 @@ MARCA_SUB = { } +_SEMILLA_POR_LARGO = sorted(DICCIONARIO_SEMILLA, key=lambda k: -len(k)) + + 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): @@ -483,7 +551,12 @@ def cubeta_carpeta(nombre: str, diccionario: dict): 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)) + # + # Solo las palabras DE FÁBRICA valen como parte de otro nombre. Lo que alguien confirma en «Por + # nombrar» es «esta carpeta es tal cosa», no «toda carpeta que lleve esta palabra lo es»: medido el + # 4-oct-2026, aceptar que una carpeta «Videos» era una grabación convirtió en grabación a «videos ia», + # y «orden» a «01- Orden», una carpeta de 65 GB con proyectos dentro. + claves = [k for k in _SEMILLA_POR_LARGO if k in diccionario] 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}»" @@ -497,7 +570,18 @@ def cubeta_carpeta(nombre: str, diccionario: dict): # 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"} + "mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular", + "fotos", "foto", "atomos"} + +# carpetas que crea la propia tarjeta de la cámara: no son el nombre de nada +_TARJETA = {"dcim", "private", "m4root", "clip", "sub", "thmbnl", "general", "avchd", "bdmv", "stream", + "takedir", "xdroot", "misc", "panasonic", "avf info", "mp root"} +_RX_TARJETA = re.compile(r"^(\d{3}[a-z_]{3,8}|[a-z]\d{3}(_[a-z0-9]+)?|\d{3}_\d{4})$", re.I) + + +def es_carpeta_de_tarjeta(nombre: str) -> bool: + """«DCIM», «100MEDIA», «PRIVATE», «A001_0305XY»: la estructura que deja la cámara en la tarjeta.""" + return norm(nombre) in _TARJETA or bool(_RX_TARJETA.match((nombre or "").strip())) def etiqueta_grabacion(nombre: str) -> str: @@ -627,11 +711,36 @@ 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 que la app crea y que no debe volver a "ordenar" (se comparan ya normalizadas). +# +# Aquí solo están las que no tienen doble: las que empiezan por «_» ya se reconocen por el guion. Antes +# estaban todas, y como al normalizar se pierde el guion, una carpeta «Exportados» hecha a mano pasaba +# por ser la «_Exportados» de la app y no se tocaba nunca: medido el 4-oct-2026, en un mismo mes +# convivían «Exportados» y «_Exportados», y «EXPORTADOS», «Portadas» y «Fotos» sueltas en otros. 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", ".organilab")} + "_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "00_Marca", "01_Proyectos", "Campañas", + "00_Grabacion", "_Archivo", "00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados", + ".organilab")} + +# Nombres que son de la app o de su estructura: no se pueden enseñar como si fueran una carpeta del +# cliente. «Por nombrar» llegó a preguntar qué era «basura» (su propia _Para borrar\basura) y a proponer +# «grabación» porque dentro había clips. +NOMBRES_DE_LA_APP = {norm(x) for x in ( + "_Para borrar", "_Papelera", "_Archivo", ".organilab", "basura", "proxies", "00_Grabacion")} + +# Lo que cuenta como material de un rodaje dentro de una carpeta de grabación. Lo demás (un proyecto, un +# guion, una imagen de IA) estaba ahí de paso y no se arrastra a 00_Grabacion\OTROS. +ROLES_RODAJE = {"camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video", "celular_foto", + "foto_camara", "video", "audio", "imagen"} +ROLES_CAMARA = {"camara_raw", "camara_video", "camara_sidecar", "audio_grabado"} + +# Subcarpetas que delatan una pieza montada (o un proyecto entero), no una tarjeta volcada: una grabación +# no trae dentro su carpeta de exportados, de portada y de música. +PARTES_PIEZA = {"EXPORTADOS", "PORTADAS", "STOCK", "IA", "GUION", "PROYECTOS", "MUSICA", "SFX"} + +# Sube cuando cambia lo que el escaneo apunta de cada archivo (cámara, rol, fecha): un inventario hecho +# con una versión anterior se vuelve a leer antes de planear nada. +VERSION_REGLAS = 2 # Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio) # Un color por tipo de material. Se usa en el mapa, en el gráfico de peso y —si se quiere— en el diff --git a/static/index.html b/static/index.html index 1bfc2b1..b958ed1 100644 --- a/static/index.html +++ b/static/index.html @@ -264,6 +264,7 @@ .nombrar li .r{display:block} .nombrar li .sg{display:block;font-size:12px;color:var(--acc);margin-top:3px;white-space:normal} .nombrar li .sg b{display:inline} + .nombrar li .nt{display:block;font-size:12px;color:var(--mut);margin-top:3px;white-space:normal} .nombrar li .acts button{white-space:nowrap} .nombrar li .acts{display:flex;gap:6px;align-items:center;justify-content:flex-end} .nombrar li .acts select{max-width:230px} @@ -425,8 +426,8 @@
La app mira qué hay dentro de cada carpeta y propone qué es. Confirma con un clic o elige otra cosa; se lo aprende para todos los discos. ?Solo se ordenan solas las carpetas reconocidas. Las que dejes sin nombrar no se mueven. Una sugerencia «segura» sale cuando casi todo el contenido es de un mismo tipo (por ejemplo, 90 % clips de cámara).
- +Aquí solo salen las carpetas sobre las que hay algo que decidir: las que la app no sabe qué son, y que no va a mover hasta que se lo digas. Confirma la propuesta con un clic o elige otra cosa. ?No salen las carpetas de la propia app, ni lo de dentro de las piezas, ni lo que ya se ordena solo: una carpeta llena de clips de cámara se reparte sin preguntar, y un cajón con meses dentro se abre. La propuesta sale de la forma de la carpeta (si trae sus exportados, su portada y su música es una pieza; si guarda meses, es un cajón) y de lo que tiene dentro contando archivos, no peso. Lo que confirmes vale para las carpetas que se llamen exactamente igual, en cualquier disco. Quita «solo sin reconocer» para ver y corregir lo que ya reconoce.
+| ${esc(r.cliente||'todos')} | ${esc(r.origen)} | → | ${esc(r.destino||'(ignorar)')} |