Files
labstream-organizador/reglas.py
Labstream 99d0ba2034 2.2.0: la fecha de verdad manda, sin preguntar nada
- R.fecha_real: la fecha sale de lo más antiguo que sabe el disco. Al copiar en
  Windows la creación pasa a ser la de la copia y la modificación se conserva;
  al renderizar, las dos son nuevas.
- R.base_derivado y _heredar_fechas: un render hereda la fecha del original, en
  la misma carpeta o en otra si el nombre es propio y único. Si hay dos
  candidatos iguales no hereda.
- El plan archiva el material en el mes en que se grabó, no en el que dice la
  carpeta, y explica lo que hizo en vez de pedir una decisión. Los 727 GB de
  las dos carpetas que mentían se resuelven solos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:01:33 -05:00

596 líneas
29 KiB
Python

# -*- coding: utf-8 -*-
"""
Reglas de clasificación del material de Labstream.
Aquí vive todo lo que "sabe" el organizador: cómo reconocer de qué cámara
viene un archivo, qué es una carpeta por su nombre, qué es basura y la
estructura canónica a la que se lleva cada cosa según el tipo de cliente.
El diccionario de carpetas se siembra desde aquí la primera vez y después
se guarda en datos/diccionario.json, donde la app va aprendiendo.
"""
import os
import re
import unicodedata
import datetime
# --------------------------------------------------------------------------
# Normalización de nombres
# --------------------------------------------------------------------------
def norm(s: str) -> str:
"""minúsculas, sin acentos, separadores colapsados a un espacio"""
s = unicodedata.normalize("NFKD", s or "")
s = "".join(c for c in s if not unicodedata.combining(c))
s = s.lower()
s = re.sub(r"[\s_\-\.:,;()\[\]{}]+", " ", s)
return s.strip()
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
"agosto", "septiembre", "octubre", "noviembre", "diciembre"]
MESES_TITULO = [m.capitalize() for m in MESES]
# abreviaturas: se entienden, pero nunca con confianza alta (ver MESES_DUDOSOS)
MESES_ALIAS = {
"setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre",
"sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo",
"abr": "abril", "may": "mayo", "jun": "junio", "jul": "julio", "ago": "agosto",
"oct": "octubre", "nov": "noviembre", "dic": "diciembre",
"january": "enero", "february": "febrero", "march": "marzo", "april": "abril",
"june": "junio", "july": "julio", "august": "agosto", "september": "septiembre",
"october": "octubre", "november": "noviembre", "december": "diciembre",
}
# «Mar», «May» o «Ago» pueden ser un nombre propio, no un mes: se proponen con confianza media
MESES_DUDOSOS = {"sep", "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "oct", "nov", "dic"}
def detectar_anio(nombre: str):
m = re.fullmatch(r"\s*(20\d\d)\s*", nombre or "")
return int(m.group(1)) if m else None
def detectar_mes(nombre: str):
"""Devuelve (num_mes, conflicto) si la carpeta ES un mes ("09- Septiembre",
"02-marzo", "MAYO", "05"). conflicto=True cuando el número no coincide con
el nombre (gana el nombre)."""
n = norm(nombre)
m = re.fullmatch(r"(\d{1,2})?\s*([a-z]*)", n)
if not m:
return None, False
num = int(m.group(1)) if m.group(1) else None
palabra = m.group(2)
abreviada = palabra in MESES_DUDOSOS
palabra = MESES_ALIAS.get(palabra, palabra)
mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None
if mes_por_nombre:
# abreviada sin número («Mar») o número que no cuadra con el nombre: confianza media
return mes_por_nombre, (num is not None and num != mes_por_nombre) or (abreviada and num is None)
if num and 1 <= num <= 12 and not palabra:
return num, True # «3» solo puede ser marzo… o la pieza número 3: se propone con confianza media, no alta
return None, False
def nombre_mes(mes: int) -> str:
"""convención de la casa: «MM- Mes» (la forma más usada en las carpetas actuales)"""
return f"{mes:02d}- {MESES_TITULO[mes - 1]}"
def detectar_pieza(nombre: str):
"""'01- Exosomas' -> (1, 'Exosomas'); None si no está numerada."""
if detectar_anio(nombre) or detectar_mes(nombre)[0]:
return None
m = re.match(r"^\s*(\d{1,2})\s*[-_.:]+\s*(.+?)\s*$", nombre or "")
if not m:
return None
num, titulo = int(m.group(1)), m.group(2)
if num == 0:
return None # "00_Grabacion" es estructura, no una pieza
titulo = re.sub(r"\s{2,}", " ", titulo).strip(" -_")
return num, titulo
def nombre_pieza(num: int, titulo: str) -> str:
"""convención de la casa: «NN- Título» (la forma más usada en las carpetas actuales)"""
return f"{num:02d}- {titulo}"
_RX_ENVATO_DIR = re.compile(r"-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-utc$", re.I)
# --------------------------------------------------------------------------
# Firmas de archivos: de qué cámara / herramienta viene
# --------------------------------------------------------------------------
FIRMAS = [
("URSA_BRAW", r"^[A-Z]\d{3}_\d{8}_C\d{3}(_\d+)?\.braw$", "Blackmagic (BRAW)"),
("BM_PROXY", r"^[A-Z]\d{3}_\d{8}_C\d{3}(_\d+)?\.(mp4|mov)$", "Blackmagic proxy H.264 / ProRes"),
("SONY", r"^\d{8}_labstream\d{4}(m01)?\.(mp4|xml)$", "Sony XAVC"),
("SONY", r"^c\d{4}(m01)?\.(mp4|mov|xml)$", "Sony"),
("OSMO", r"^dji_\d{14}_\d{4}_d\.(mp4|mov|lrf)$", "DJI Osmo Pocket"),
("DJI", r"^dji_\d{4}\.(mp4|mov|jpg|dng|lrf|srt)$", "DJI dron / Osmo"),
("DJI_MIC", r"^dji_\d{2}_\d{8}_\d{6}\.wav$", "DJI Mic"),
("TASCAM", r"^tascam_\d{4}\.wav$", "Tascam"),
("ATOMOS", r"^ninjav_", "Atomos Ninja V"),
("CELULAR", r"^img_\d{4}( \(\d+\))?( \d)?(_[a-z0-9]+)?\.(mov|mp4|heic|jpg|jpeg|png)$", "iPhone"),
("CELULAR", r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(mov|heic|jpg|jpeg|mp4|png)$", "iPhone (AirDrop)"),
("CELULAR", r"^(vid|pxl)_\d{8}", "Android"),
("WHATSAPP", r"^whatsapp (image|video|audio) ", "WhatsApp"),
("PANASONIC", r"^_?\d{7}\.(rw2|jpg)$", "Panasonic Lumix"),
("SONY_FOTO", r"^dsc\d{5}\.(arw|jpg)$", "Sony foto"),
("IA_HIGGSFIELD", r"^hf_\d{8}_\d{6}_", "Higgsfield"),
("IA_CHATGPT", r"^chatgpt image ", "ChatGPT"),
("IA_VIDEO", r"^(animate_this_image|video_\d|kling|runway|veo_|sora)", "IA video"),
("IA_VOZ", r"^elevenlabs_", "ElevenLabs"),
("STOCK", r"-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-utc(\b|[ .(])", "Envato Elements"),
("CAPTURA", r"^(captura de pantalla|grabaci[oó]n de pantalla|screenshot|screen recording|grabaci[oó]n \d{4})", "Captura de pantalla"),
]
FIRMAS_RE = [(k, re.compile(p, re.I), d) for k, p, d in FIRMAS]
FUENTE_CARPETA = { # nombre de subcarpeta al separar una sesión de grabación por fuente
"URSA_BRAW": "URSA_BRAW", "BM_PROXY": "BLACKMAGIC_MP4", "SONY": "SONY", "OSMO": "OSMO",
"DJI": "DJI", "DJI_MIC": "AUDIO", "TASCAM": "AUDIO", "ATOMOS": "ATOMOS",
"CELULAR": "CELULAR", "WHATSAPP": "CELULAR", "PANASONIC": "FOTOS", "SONY_FOTO": "FOTOS",
}
def firma_archivo(nombre: str):
for k, rx, desc in FIRMAS_RE:
if rx.search(nombre):
return k
return None
# --------------------------------------------------------------------------
# Extensiones
# --------------------------------------------------------------------------
EXT = {
"video": {".mov", ".mp4", ".m4v", ".mxf", ".mts", ".avi", ".mkv", ".webm", ".braw", ".r3d", ".m2ts"},
"video_raw": {".braw", ".r3d", ".ari"},
"audio": {".wav", ".mp3", ".aac", ".m4a", ".aif", ".aiff", ".flac", ".ogg", ".wma"},
"imagen": {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp", ".svg"},
"foto_raw": {".arw", ".rw2", ".dng", ".cr2", ".cr3", ".nef", ".raf", ".xmp"},
"diseno": {".psd", ".psb", ".ai", ".af", ".afphoto", ".afdesign", ".indd", ".xd", ".fig", ".sketch", ".eps"},
"proyecto": {".drp", ".drb", ".drt", ".dra", ".prproj", ".aep", ".prin", ".fcpxml", ".aaf", ".edl", ".otio", ".xml", ".comp", ".setting", ".mogrt", ".drfx"},
"documento": {".pdf", ".docx", ".doc", ".md", ".txt", ".pptx", ".xlsx", ".csv", ".rtf", ".ttml", ".srt", ".vtt", ".pages", ".key", ".numbers"},
"fuente": {".ttf", ".otf", ".woff", ".woff2"},
"comprimido": {".zip", ".rar", ".7z", ".tar", ".gz", ".dmg", ".iso", ".img"},
"lut": {".cube", ".3dl", ".look"},
"programa": {".exe", ".msi", ".pkg", ".app", ".lnk"},
}
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"}
# solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos
# (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito.
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk"}
BASURA_DIRS = {"__macosx", "#recycle", ".synologyworkingdirectory", "@eadir", "media cache", "media cache files", "peak files"}
BASURA_DIRS_CONTIENE = ["almacenamiento automatico", "auto save", "autosave", "auto-save", "adobe premiere pro auto"]
# «cache» a secas no entra: es un nombre demasiado genérico para mandar la carpeta entera a _Para borrar
PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media",
"cache files", "render cache"}
def es_basura_archivo(nombre: str) -> bool:
n = nombre.lower()
if n in BASURA_NOMBRES or n.startswith("._"):
return True
if n.endswith("~lock~") or n.endswith("~"):
return True
ext = n[n.rfind("."):] if "." in n else ""
if ext in BASURA_EXT:
return True
if re.match(r"^ps[a-z0-9]{3,5}\.tmp$", n) or re.match(r"^adobe photoshop \d{10,}$", n):
return True
if re.match(r"^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\.tmp$", n):
return True
return False
def es_basura_dir(nombre: str) -> bool:
n = norm(nombre)
if n in BASURA_DIRS or nombre.lower() in BASURA_DIRS:
return True
return any(t in n for t in BASURA_DIRS_CONTIENE)
def es_proxy_dir(nombre: str) -> bool:
return norm(nombre) in PROXY_DIRS or nombre.lower() in {".proxy"}
def rol_archivo(nombre: str, ext: str, firma: str | None) -> str:
"""Qué es el archivo por sí mismo (sin mirar la carpeta)."""
e = ext.lower()
if es_basura_archivo(nombre):
return "basura"
if firma in ("URSA_BRAW",) or e in EXT["video_raw"]:
return "camara_raw"
if firma in ("BM_PROXY", "SONY", "OSMO", "DJI", "ATOMOS") and e in EXT["video"]:
return "camara_video"
if firma == "SONY" and e == ".xml":
return "camara_sidecar"
if firma in ("CELULAR", "WHATSAPP") and e in EXT["video"]:
return "celular_video"
if firma in ("CELULAR", "WHATSAPP") and e in EXT["imagen"]:
return "celular_foto"
if firma in ("DJI_MIC", "TASCAM"):
return "audio_grabado"
if firma in ("PANASONIC", "SONY_FOTO") or e in EXT["foto_raw"]:
return "foto_camara"
if firma and firma.startswith("IA_"):
return "ia"
if firma == "STOCK":
return "stock"
if firma == "CAPTURA":
return "captura"
if e == ".lrf":
return "proxy"
if e in EXT["video"]:
return "video"
if e in EXT["audio"]:
return "audio"
if e in EXT["imagen"]:
return "imagen"
if e in EXT["diseno"]:
return "diseno"
if e in EXT["proyecto"]:
return "proyecto"
if e in EXT["documento"]:
return "documento"
if e in EXT["fuente"]:
return "fuente"
if e in EXT["comprimido"]:
return "comprimido"
if e in EXT["lut"]:
return "lut"
if e in EXT["programa"]:
return "programa"
return "otro"
# --------------------------------------------------------------------------
# Fecha a partir del nombre del archivo
# --------------------------------------------------------------------------
_RX_YMD = re.compile(r"(?<!\d)(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])(?!\d)")
_RX_YMD_HMS = re.compile(r"(?<!\d)(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{6}(?!\d)")
_RX_Y_M_D = re.compile(r"(?<!\d)(20\d{2})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?!\d)")
_RX_ES = re.compile(r"(\d{1,2}) (ene|feb|mar|abr|may|jun|jul|ago|sep|oct|nov|dic)\w*\.? (20\d{2})", re.I)
_RX_BM = re.compile(r"^[A-Z]\d{3}_(\d{2})(\d{2})\d{4}_C\d{3}", re.I)
_RX_WA = re.compile(r"WhatsApp \w+ (20\d{2})-(\d{2})-(\d{2})", re.I)
# sufijos que la propia app (o el editor) le pone a un archivo derivado de otro: el derivado se
# genera mucho después, pero la fecha que importa es la del material original
SUFIJOS_DERIVADO = ("_10bit", "_ligero", "_h264", "_proxy", "_lt", "_hq", "_prores", "_convertido",
"_export", "_render", "_master", "_final")
def base_derivado(nombre: str):
"""Nombre del archivo del que este parece salir, o None. «C0001_10bit.mp4» -> «C0001»."""
base = os.path.splitext(nombre)[0]
b = base.lower()
for suf in SUFIJOS_DERIVADO:
if b.endswith(suf) and len(base) > len(suf):
return base[:-len(suf)]
return None
def fecha_real(st):
"""El instante más antiguo que el disco sabe de este archivo.
Al copiar en Windows, la fecha de creación pasa a ser la de la copia y la de modificación se
conserva; al renderizar o reexportar, las dos son nuevas. Quedarse con la más antigua de las dos
da siempre la mejor estimación de cuándo se grabó, sin tener que elegir una de antemano."""
c = getattr(st, "st_birthtime", None) or st.st_ctime
return min(st.st_mtime, c) if c and c > 0 else st.st_mtime
def fecha_de_nombre(nombre: str, mtime: float):
"""(YYYY-MM-DD, origen) — origen 'nombre' si sale del nombre, 'mtime' si no."""
for rx in (_RX_YMD, _RX_YMD_HMS, _RX_Y_M_D, _RX_WA):
m = rx.search(nombre)
if m:
try:
d = datetime.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
return d.isoformat(), "nombre"
except ValueError:
pass
m = _RX_ES.search(nombre)
if m:
mes = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"].index(m.group(2).lower()[:3]) + 1
try:
return datetime.date(int(m.group(3)), mes, int(m.group(1))).isoformat(), "nombre"
except ValueError:
pass
m = _RX_BM.match(nombre)
if m:
mes, dia = int(m.group(1)), int(m.group(2))
if 1 <= mes <= 12 and 1 <= dia <= 31:
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"
except ValueError:
pass
return datetime.date.fromtimestamp(mtime).isoformat(), "mtime"
# --------------------------------------------------------------------------
# Cubetas (buckets): qué es una carpeta
# --------------------------------------------------------------------------
CUBETAS = {
"GRABACION": "Grabación (originales de cámara de una sesión)",
"PIEZA": "Pieza de contenido (un reel, un video)",
"CAMPANA": "Campaña de marca / pauta",
"EXPORTADOS": "Exportados / masters finales",
"PORTADAS": "Portadas y miniaturas",
"APOYOS": "Tomas de apoyo reutilizables (b-roll)",
"MARCA": "Material de marca (logos, fuentes, fondos, transiciones)",
"PROYECTOS": "Proyectos de edición (Resolve, Premiere, AE, Photoshop)",
"MUSICA": "Música",
"SFX": "Efectos de sonido",
"VOZ": "Voces / locuciones",
"IA": "Generado con IA",
"STOCK": "Stock (Envato, etc.)",
"GUION": "Guiones y textos",
"FOTOS": "Fotografías",
"DOCUMENTOS": "Documentos, propuestas, referentes",
"CELULAR": "Material de celular",
"PROXY": "Proxies (se pueden regenerar)",
"BASURA": "Basura de sistema / cachés",
"CONTENEDOR": "Carpeta contenedora: mirar adentro",
"REVISAR": "Revisar a mano",
"IGNORAR": "Dejar como está",
}
# token normalizado -> cubeta. Se siembra en datos/diccionario.json y de ahí aprende.
DICCIONARIO_SEMILLA = {
# grabación
"grabacion": "GRABACION", "grabaciones": "GRABACION", "grabado": "GRABACION", "grabados": "GRABACION",
"tomas": "GRABACION", "rushes": "GRABACION", "brutos": "GRABACION", "raw": "GRABACION",
"ursa": "GRABACION", "braw": "GRABACION", "sony": "GRABACION", "osmo": "GRABACION", "dji": "GRABACION", "blackmagic": "GRABACION",
"blackmagic braw": "GRABACION", "otros blackmagic": "GRABACION", "otro sony": "GRABACION", "dji audio": "GRABACION",
"tascam": "GRABACION", "camara": "GRABACION", "videos originales": "GRABACION", "originales": "GRABACION",
# exportados
"render": "EXPORTADOS", "renders": "EXPORTADOS", "export": "EXPORTADOS", "exportado": "EXPORTADOS",
"exportados": "EXPORTADOS", "exports": "EXPORTADOS", "finales": "EXPORTADOS", "final": "EXPORTADOS",
"videos finales": "EXPORTADOS", "videos finales pantalla": "EXPORTADOS", "finales enviar": "EXPORTADOS",
"entrega": "EXPORTADOS", "entregas": "EXPORTADOS", "master": "EXPORTADOS", "masters": "EXPORTADOS",
"entregable": "EXPORTADOS", "entregables": "EXPORTADOS", "publicados": "EXPORTADOS",
# portadas
"portada": "PORTADAS", "portadas": "PORTADAS", "miniatura": "PORTADAS", "miniaturas": "PORTADAS",
"thumbnails": "PORTADAS", "thumbnail": "PORTADAS", "covers": "PORTADAS", "cover": "PORTADAS",
# marca
"material de marca": "MARCA", "marca": "MARCA", "manual de marca": "MARCA", "branding": "MARCA",
"logos": "MARCA", "logo": "MARCA", "logos png": "MARCA", "fuente": "MARCA", "fuentes": "MARCA",
"tipografia": "MARCA", "tipografias": "MARCA", "fondo": "MARCA", "fondos": "MARCA",
"transitions": "MARCA", "transiciones": "MARCA", "efectos": "MARCA", "texto": "MARCA", "textos": "MARCA",
"intro": "MARCA", "intros": "MARCA", "plantillas": "MARCA", "luts": "MARCA",
# proyectos
"adobe": "PROYECTOS", "proyecto": "PROYECTOS", "proyectos": "PROYECTOS", "premiere": "PROYECTOS",
"resolve": "PROYECTOS", "after": "PROYECTOS", "after effects": "PROYECTOS", "photoshop": "PROYECTOS",
"animaciones": "PROYECTOS", "prerenders": "PROYECTOS", "socalo": "PROYECTOS",
# música / sonido / voz
"musica": "MUSICA", "music": "MUSICA", "canciones": "MUSICA", "soundtrack": "MUSICA", "julio music": "MUSICA",
"sonidos": "SFX", "sfx": "SFX", "swoosh": "SFX", "whoosh": "SFX", "efectos de sonido": "SFX",
"voces": "VOZ", "voz": "VOZ", "elevenlabs": "VOZ", "locucion": "VOZ", "locuciones": "VOZ", "narracion": "VOZ",
"audio": "VOZ", "audios": "VOZ",
# ia
"ia": "IA", "ai": "IA", "imagenes ia": "IA", "imagenes ia importante": "IA", "avatars": "IA", "avatares": "IA",
"higgsfield": "IA", "chatgpt": "IA", "generado": "IA",
# stock
"stock": "STOCK", "envato": "STOCK", "recursos": "STOCK", "elements": "STOCK", "recursos audiovisuales": "STOCK",
# guion / documentos
"guion": "GUION", "guiones": "GUION", "script": "GUION", "scripts": "GUION",
"propuesta": "DOCUMENTOS", "propuestas": "DOCUMENTOS", "referentes": "DOCUMENTOS", "referencia": "DOCUMENTOS",
"referencias": "DOCUMENTOS", "imagen referencia": "DOCUMENTOS", "brief": "DOCUMENTOS", "documentos": "DOCUMENTOS",
"claude": "DOCUMENTOS", "conferencia": "DOCUMENTOS",
# fotos
"fotos": "FOTOS", "fotografias": "FOTOS", "fotografia": "FOTOS", "jpeg": "FOTOS", "imagenes": "FOTOS", "imagen": "FOTOS",
# apoyos
"tomas de apoyo": "APOYOS", "tomas apoyos": "APOYOS", "apoyos": "APOYOS", "apoyo": "APOYOS", "b roll": "APOYOS",
"broll": "APOYOS", "rellenos": "APOYOS",
# campañas de marca
"dove": "CAMPANA", "loreal": "CAMPANA", "garnier": "CAMPANA", "isdin": "CAMPANA", "cetaphil": "CAMPANA",
"artemis": "CAMPANA", "vinicius": "CAMPANA", "the organic pharmacy": "CAMPANA", "nixoderm": "CAMPANA",
"publicidad": "CAMPANA", "pautas facebook": "CAMPANA", "pauta": "CAMPANA", "campana": "CAMPANA",
"campanas": "CAMPANA", "anuncio": "CAMPANA", "ads": "CAMPANA", "vsl": "CAMPANA",
"vsls": "CAMPANA", "lanzamientos": "CAMPANA", "tanda": "CAMPANA",
# celular
"celular": "CELULAR", "material celular": "CELULAR", "iphone": "CELULAR", "calular pero de": "CELULAR",
# proxies y basura
"proxy": "PROXY", "proxies": "PROXY", "macosx": "BASURA", "recycle": "BASURA",
# contenedores: hay que mirar adentro
"nuevo": "CONTENEDOR", "nuevo orden": "CONTENEDOR", "nuevo 2026": "CONTENEDOR", "anuncios": "CONTENEDOR",
"material": "IGNORAR", # "Material" es estructura (de una campaña o de la raíz de pauta): no se mueve
"otros videos": "CONTENEDOR", "otros": "CONTENEDOR", "varios": "CONTENEDOR", "sin fecha": "CONTENEDOR",
"temporal": "CONTENEDOR", "temp": "CONTENEDOR", "desordenado": "CONTENEDOR", "desordenado revisar": "CONTENEDOR",
"sacado de nubes": "CONTENEDOR", "cambio de proyecto": "CONTENEDOR", "nueva carpeta": "CONTENEDOR",
"repetidos": "IGNORAR", "duplicados": "IGNORAR", "backup": "REVISAR", "respaldo": "REVISAR",
# cosas del cliente que son piezas o series
"reels": "PIEZA", "reel": "PIEZA", "historia": "PIEZA", "historias": "PIEZA", "carrusel": "PIEZA",
"podcast": "PIEZA", "consultorio": "PIEZA", "testimonios": "PIEZA", "clases": "PIEZA",
# subcarpetas normales DENTRO de una pieza: se dejan como están
"visual": "IGNORAR", "visula": "IGNORAR", "01 visual": "IGNORAR", "chroma key": "IGNORAR",
"prerenders": "IGNORAR", "elements": "STOCK", "elemnts": "STOCK", "stems": "MUSICA", "loops": "MUSICA",
"shorts": "MUSICA", "full": "MUSICA", "exportada": "EXPORTADOS", "higgsfiel": "IA",
}
# sub-carpeta canónica dentro de 00_Marca
MARCA_SUB = {
"fuente": "Fuentes", "fuentes": "Fuentes", "tipografia": "Fuentes", "tipografias": "Fuentes",
"fondo": "Fondos", "fondos": "Fondos", "transitions": "Transiciones", "transiciones": "Transiciones",
"efectos": "Transiciones", "sonidos": "Sonidos", "texto": "Textos", "textos": "Textos",
"logos": "Logos", "logo": "Logos", "logos png": "Logos", "manual de marca": "Manual", "intro": "Intros",
"intros": "Intros", "luts": "LUTs", "plantillas": "Plantillas",
}
def cubeta_carpeta(nombre: str, diccionario: dict):
"""(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce."""
if es_basura_dir(nombre):
return "BASURA", "basura de sistema"
if es_proxy_dir(nombre):
return "PROXY", "carpeta de proxies"
if _RX_ENVATO_DIR.search(nombre.strip()):
return "STOCK", "nombre de paquete de Envato"
n = norm(nombre)
if n in diccionario:
return diccionario[n], f"diccionario: «{n}»"
# tokens de varias palabras primero, luego palabras sueltas
# (CONTENEDOR solo por coincidencia exacta: "Material juan manuel" no es un cajón de sastre)
claves = sorted(diccionario.keys(), key=lambda k: -len(k))
for k in claves:
if " " in k and diccionario[k] != "CONTENEDOR" and re.search(r"(^| )" + re.escape(k) + r"( |$)", n):
return diccionario[k], f"diccionario: contiene «{k}»"
palabras = n.split()
for k in claves:
if " " not in k and diccionario[k] != "CONTENEDOR" and k in palabras:
return diccionario[k], f"diccionario: palabra «{k}»"
return None, ""
# palabras que solo dicen "de qué cámara": no aportan etiqueta a una sesión de grabación
FUENTE_PALABRAS = {"grabacion", "grabaciones", "grabado", "grabados", "ursa", "sony", "osmo", "dji", "blackmagic",
"braw", "tascam", "audio", "otro", "otros", "a", "c", "camara", "material", "video", "videos",
"mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular"}
def etiqueta_grabacion(nombre: str) -> str:
"""'Grabación Loreal Peeling' -> 'Loreal Peeling'; 'Grabación URSA' -> ''"""
palabras = [p for p in re.sub(r"\s{2,}", " ", nombre).strip(" -_").split(" ") if p]
def sobra(w):
return w in FUENTE_PALABRAS or w.isdigit() or MESES_ALIAS.get(w, w) in MESES or w in ("-", "_")
utiles = [p for p in palabras if not all(sobra(w) for w in norm(p).split()) and norm(p)]
if not utiles:
return ""
return " ".join(utiles)
# --------------------------------------------------------------------------
# Plantillas de estructura por tipo de cliente
# --------------------------------------------------------------------------
TIPOS_CLIENTE = {
"contenido": "Contenido mensual (dermatólogas, marca personal): año / mes / pieza",
"pauta": "Pauta y anuncios (campañas por tandas, ganchos, VSL)",
"proyecto": "Proyecto puntual corporativo (un trabajo, una entrega)",
"interno": "Interno de Labstream (recursos compartidos)",
}
# Destinos relativos a la raíz del cliente. {anio} {mes} = "08-Agosto" {nombre} {fuente}
PLANTILLAS = {
"contenido": {
"MARCA": "00_Marca/{sub}",
"PROYECTOS": "01_Proyectos/{nombre}",
"GRABACION": "{anio}/{mes}/00_Grabacion/{sub}",
"PIEZA": "{anio}/{mes}/{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}",
"EXPORTADOS": "{anio}/{mes}/_Exportados/{sub}",
"PORTADAS": "{anio}/{mes}/_Portadas/{sub}",
"APOYOS": "_Apoyos/{sub}",
"MUSICA": "_Recursos/Musica/{sub}",
"SFX": "_Recursos/Sonidos/{sub}",
"VOZ": "{anio}/{mes}/_Voces/{sub}",
"IA": "{anio}/{mes}/_IA/{sub}",
"STOCK": "_Recursos/Stock/{sub}",
"GUION": "{anio}/{mes}/_Guiones/{sub}",
"FOTOS": "{anio}/{mes}/_Fotos/{sub}",
"DOCUMENTOS": "_Documentos/{sub}",
"CELULAR": "{anio}/{mes}/00_Grabacion/CELULAR/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
"pauta": {
"MARCA": "00_Marca/{sub}",
"PROYECTOS": "01_Proyectos/{nombre}",
"GRABACION": "Material/Grabaciones/{anio}/{mes}/{sub}",
"PIEZA": "Campañas/{anio}_{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}",
"EXPORTADOS": "_Exportados/{anio}/{sub}",
"PORTADAS": "_Portadas/{sub}",
"APOYOS": "Material/Apoyos/{sub}",
"MUSICA": "_Recursos/Musica/{sub}",
"SFX": "_Recursos/Sonidos/{sub}",
"VOZ": "Material/Voces/{sub}",
"IA": "Material/IA/{sub}",
"STOCK": "_Recursos/Stock/{sub}",
"GUION": "_Guiones/{sub}",
"FOTOS": "Material/Fotos/{sub}",
"DOCUMENTOS": "_Documentos/{sub}",
"CELULAR": "Material/Celular/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
"proyecto": {
"MARCA": "00_Marca/{sub}",
"PROYECTOS": "03_Proyecto/{nombre}",
"GRABACION": "02_Grabacion/{anio}/{mes}/{sub}",
"PIEZA": "04_Exportados/{nombre}",
"CAMPANA": "{anio}_{nombre}",
"EXPORTADOS": "04_Exportados/{sub}",
"PORTADAS": "04_Exportados/Portadas/{sub}",
"APOYOS": "01_Material/Apoyos/{sub}",
"MUSICA": "01_Material/Musica/{sub}",
"SFX": "01_Material/Sonidos/{sub}",
"VOZ": "01_Material/Voces/{sub}",
"IA": "01_Material/IA/{sub}",
"STOCK": "01_Material/Stock/{sub}",
"GUION": "00_Brief/{sub}",
"FOTOS": "01_Material/Fotos/{sub}",
"DOCUMENTOS": "00_Brief/{sub}",
"CELULAR": "01_Material/Celular/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
"interno": {
"MARCA": "Marca/{sub}",
"PROYECTOS": "Proyectos/{nombre}",
"GRABACION": "Grabaciones/{anio}/{mes}/{sub}",
"PIEZA": "Piezas/{anio}/{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}",
"EXPORTADOS": "Exportados/{anio}/{sub}",
"PORTADAS": "Portadas/{sub}",
"APOYOS": "Apoyos/{sub}",
"MUSICA": "Musica/{sub}",
"SFX": "Sonidos/{sub}",
"VOZ": "Voces/{sub}",
"IA": "IA/{sub}",
"STOCK": "Stock/{sub}",
"GUION": "Guiones/{sub}",
"FOTOS": "Fotos/{sub}",
"DOCUMENTOS": "Documentos/{sub}",
"CELULAR": "Celular/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
}
# La app NUNCA borra: lo que sobra va a esta carpeta dentro de la misma raíz y una persona la revisa y la borra a mano.
PAPELERA = "_Para borrar"
PAPELERAS = ("_Para borrar", "_Papelera") # el nombre viejo se sigue reconociendo en carpetas ya ordenadas
PAPELERAS_NORM = {norm(x) for x in PAPELERAS}
# Los duplicados se juntan en esta subcarpeta AL LADO del archivo que se queda, para revisarlos a mano.
DUPLICADOS = "Duplicados"
# Carpetas que la app crea y que no debe volver a "ordenar" (se comparan ya normalizadas)
CARPETAS_PROPIAS = {norm(x) for x in (
"_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas",
"_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo",
"00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados")}
# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio)
ROL_A_CUBETA = {
"camara_raw": "GRABACION", "camara_video": "GRABACION", "camara_sidecar": "GRABACION",
"audio_grabado": "GRABACION", "celular_video": "CELULAR", "celular_foto": "CELULAR",
"foto_camara": "FOTOS", "ia": "IA", "stock": "STOCK", "captura": "REVISAR", "proxy": "PROXY",
"video": "EXPORTADOS", "audio": "MUSICA", "imagen": "PORTADAS", "diseno": "PROYECTOS",
"proyecto": "PROYECTOS", "documento": "DOCUMENTOS", "fuente": "MARCA", "comprimido": "REVISAR",
"lut": "MARCA", "programa": "REVISAR", "otro": "REVISAR", "basura": "BASURA",
}