3.2.0: el orden mira la forma de las carpetas, no solo su nombre

Medido sobre una carpeta de cliente real (8.207 archivos, 920 GB) antes de tocar nada:

- «Por nombrar» solo pregunta por lo que el plan llega a ver y no sabe resolver; ya no por las
  carpetas de la app ni por lo de dentro de la papelera. Lo aprendido vale por el nombre entero.
- Una carpeta es una grabación por lo que tiene dentro: una pieza montada no se desarma, y lo
  que no es rodaje no acaba en 00_Grabacion\OTROS. Se conserva el nombre de cada sesión.
- Una carpeta que guarda meses o años dentro se abre y cada cosa va a su mes; si la pieza ya
  existe allí, se junta con ella.
- Repaso de lo ya ordenado: lo que está en un mes que no es el de su rodaje vuelve al suyo.
- El año de un clip de Blackmagic sale de los demás clips de su tarjeta y día; un rodaje que
  pasa de medianoche cuenta como uno.
- Un clip convertido sigue siendo de su cámara, y el convertidor no repite lo que ya convirtió.
- «Exportados» junto a «_Exportados» y «_Papelera» junto a «_Para borrar» se juntan.
- «Hacer de una vez» solo mueve lo marcado; lo dudoso que queda no cuenta como pendiente.
- El inventario lleva la versión de las reglas: uno anterior pide escanear otra vez.

Pruebas: prueba_orden.py (nueva, 19 casos), probar.py 19, prueba_funcional.py 26,
prueba_ventana.py 18 desde el código y 17 con el paquete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-04 16:33:45 -05:00
padre 45ece8f2e7
commit c419d5d943
Se han modificado 8 ficheros con 1334 adiciones y 184 borrados

129
reglas.py
Ver fichero

@@ -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