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:
129
reglas.py
129
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
|
||||
|
||||
Referencia en una nueva incidencia
Block a user