3.3.0: lo que entra nuevo ya entra ordenado

Las cuatro funciones que eligió Munka tras la 3.2.0:

- Nueva pieza: crea «NN- Título» en su mes con el número que toca y sus subcarpetas; si la
  pieza ya existe, lo dice. Se deshace mientras siga vacía.
- Bandeja de entrada: lo que se deje en _Entrada se reparte por mes, día de rodaje y cámara.
  «Traer material» copia una tarjeta comprobando cada archivo (se vuelve a leer y se compara
  la huella), conserva la fecha del rodaje y no toca el origen.
- Versiones del mismo clip: dos clips de cámara que se llaman igual, duran lo mismo y tienen
  la misma forma son el mismo clip; se queda el de más imagen y el otro va a Duplicados.
- Orden automático, apagado de fábrica: una tarea del Programador de tareas de Windows lanza
  «automatico», que lee todas las carpetas y hace solo lo seguro. No toca una carpeta que no
  se pueda leer entera ni una cuyo plan mueva más de 3.000 archivos, y si el programa ya está
  abierto se lo pide a él. Desinstalar quita la tarea.

De paso: una ruta que empieza por guion bajo ya no pierde el guion al ir a la papelera.

Pruebas: prueba_orden.py 24, probar.py 19, prueba_funcional.py 29, 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 17:05:57 -05:00
padre c419d5d943
commit 477857db45
Se han modificado 11 ficheros con 1344 adiciones y 54 borrados

Ver fichero

@@ -382,6 +382,225 @@ class Motor(unittest.TestCase):
self.assertTrue(any("versión anterior" in x for x in O.Plan(O.raiz_cfg(raiz)).correr()["avisos"]))
class Nuevo(unittest.TestCase):
"""Lo que entra nuevo: una pieza, el material de una tarjeta, y la pasada automática."""
def test_pieza_nueva(self):
"""Las piezas se creaban a mano: dos «01» en el mismo mes, «03 - X» junto a «03- X», «portasda»."""
raiz = cliente("Piezas")
mk(raiz, "2026/10- Octubre/01- Uno/a.mp4", 2026, 10, 1)
mk(raiz, "2026/10- Octubre/02- Dos/b.mp4", 2026, 10, 2)
plan(raiz)
previa = O.nueva_pieza(raiz, "Ozempic face", 2026, 10, crear=False)
self.assertEqual((previa["rel"], previa["existe"]), (rel("2026", "10- Octubre", "03- Ozempic face"), ""))
self.assertFalse(os.path.exists(previa["ruta"]), "la vista previa no debe crear nada")
r = O.nueva_pieza(raiz, "Ozempic face", 2026, 10)
self.assertTrue(r.get("ok"), r)
subs = sorted(os.listdir(r["ruta"]))
self.assertEqual(subs, sorted(O.CONFIG_DEFECTO["pieza"]["subcarpetas"]))
self.assertIn("ya existe", O.nueva_pieza(raiz, "ozempic FACE", 2026, 10).get("error", "")) # la misma, escrita distinto
self.assertEqual(O.nueva_pieza(raiz, "07- Otra cosa", 2026, 10, crear=False)["nombre"], "04- Otra cosa") # el número lo pone la app
for malo in ("", "a/b", "x" * 90):
self.assertTrue(O.nueva_pieza(raiz, malo, 2026, 10).get("error"), malo)
mes_nuevo = O.nueva_pieza(raiz, "Primera", 2026, 11)
self.assertEqual(mes_nuevo["rel"], rel("2026", "11- Noviembre", "01- Primera"))
# una pieza recién creada no es un hueco que borrar, ni el mes que se creó para ella
self.assertEqual(O.carpetas_vacias(raiz)["carpetas"], [])
self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"]], "el plan quiere mover una pieza recién creada")
# deshacer la quita si sigue vacía; si ya tiene algo dentro, la deja
mk(raiz, "2026/11- Noviembre/01- Primera/Guion/guion.docx", 2026, 11, 2)
d = O.deshacer(mes_nuevo["lote"])
self.assertTrue(os.path.isfile(os.path.join(mes_nuevo["ruta"], "Guion", "guion.docx")) and d["errores"], d)
d = O.deshacer(r["lote"])
self.assertFalse(os.path.exists(r["ruta"]) or d["errores"], d)
# donde las piezas no van por mes (campañas), sin número
anuncios = cliente("Piezas de pauta", "pauta")
self.assertEqual(O.nueva_pieza(anuncios, "Navidad", 2026, 12)["rel"], rel("Campañas", "2026_Navidad"))
def test_bandeja_de_entrada(self):
"""Lo que se deja en _Entrada se reparte solo por rodaje y cámara, y la bandeja queda vacía."""
raiz = cliente("Bandeja")
mk(raiz, "2026/09- Septiembre/01- Pieza/a.png", 2026, 9, 1)
t = "_Entrada/Tarjeta A/PRIVATE/M4ROOT/"
mk(raiz, t + "CLIP/C0001.MP4", 2026, 9, 20)
mk(raiz, t + "CLIP/C0001M01.XML", 2026, 9, 20)
mk(raiz, t + "MEDIAPRO.XML", 2026, 9, 20)
mk(raiz, t + "THMBNL/C0001T01.JPG", 2026, 9, 20)
mk(raiz, t + "SUB/C0001S03.MP4", 2026, 9, 20)
mk(raiz, "_Entrada/A001_09211200_C001.braw", 2026, 9, 21)
mk(raiz, "_Entrada/Entrevista Dra/C0005.MP4", 2026, 9, 22)
antes = nombres(raiz)
plan(raiz)
self.assertEqual([a["clave"] for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"], ["entrada"])
self.assertGreaterEqual(O.entrada(raiz)["n"], 4)
r = O.ordenar_entrada(raiz)
self.assertTrue(r.get("ok") and r["movimientos"] and not r["errores"], r)
self.assertEqual(nombres(raiz), antes)
g = os.path.join(raiz, "2026", "09- Septiembre", "00_Grabacion")
for esperado in (os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001.MP4"),
os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001M01.XML"),
os.path.join(g, R.nombre_dia("2026-09-21"), "URSA_BRAW", "A001_09211200_C001.braw"),
os.path.join(g, R.nombre_dia("2026-09-22"), "Entrevista Dra", "SONY", "C0005.MP4"),
os.path.join(raiz, "_Para borrar", "proxies", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "SUB", "C0001S03.MP4"),
os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "THMBNL", "C0001T01.JPG"),
os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "MEDIAPRO.XML")):
self.assertTrue(os.path.isfile(esperado), "falta " + os.path.relpath(esperado, raiz) + "\n" + "\n".join(arbol(raiz)))
# la bandeja se queda, vacía y sin el esqueleto de la tarjeta, y no sale como hueco que borrar
self.assertEqual(os.listdir(os.path.join(raiz, "_Entrada")), [])
self.assertEqual((r["quedan"], O.entrada(raiz)["n"]), (0, 0))
self.assertFalse([a for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"])
self.assertNotIn("_Entrada", [c["rel"] for c in O.carpetas_vacias(raiz)["carpetas"]])
# y se deshace: cada cosa vuelve a su sitio dentro de la tarjeta
d = O.deshacer(r["lote"])
self.assertFalse(d["errores"], d)
self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "CLIP", "C0001.MP4")))
self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Entrevista Dra", "C0005.MP4")))
def test_traer_de_una_tarjeta(self):
"""La copia desde la tarjeta se comprueba, conserva la fecha del rodaje y no toca el origen."""
raiz = cliente("Traer")
mk(raiz, "2026/08- Agosto/01- Pieza/a.png", 2026, 8, 1)
tarjeta = os.path.join(TMP, "tarjeta-sd")
clip = mk(tarjeta, "DCIM/100MEDIA/DJI_0001.MP4", 2026, 8, 10, tam=300_000)
mk(tarjeta, "System Volume Information/indice.dat", 2026, 8, 10)
with open(clip, "rb") as fh:
contenido = fh.read()
self.assertTrue(O.plan_traer(raiz, os.path.join(raiz, "2026")).get("error")) # de dentro no se trae
self.assertTrue(O.plan_traer(raiz, os.path.join(TMP, "no-existe")).get("error"))
p = O.plan_traer(raiz, tarjeta)
self.assertEqual((p["n"], p["bytes"]), (1, 300_000), p)
self.assertTrue(p["carpeta"].startswith("Tarjeta "))
r = O.traer(raiz, tarjeta)
self.assertEqual((r["estado"], r["copiados"], r["errores"]), ("listo", 1, []), r)
self.assertTrue(r["ordenado"]["ok"] and not r["ordenado"]["errores"], r["ordenado"])
final = os.path.join(raiz, "2026", "08- Agosto", "00_Grabacion", "DJI", "DJI_0001.MP4")
self.assertTrue(os.path.isfile(final), "\n".join(arbol(raiz)))
with open(final, "rb") as fh:
self.assertEqual(fh.read(), contenido)
self.assertLess(abs(os.path.getmtime(final) - os.path.getmtime(clip)), 2, "no conservó la fecha del rodaje")
with open(clip, "rb") as fh:
self.assertEqual(fh.read(), contenido, "tocó el origen")
self.assertEqual(arbol(tarjeta), [rel("DCIM", "100MEDIA", "DJI_0001.MP4"), rel("System Volume Information", "indice.dat")])
actas = os.listdir(os.path.join(raiz, ".organilab", "entradas"))
self.assertEqual(len(actas), 1)
with open(os.path.join(raiz, ".organilab", "entradas", actas[0]), encoding="utf-8-sig") as fh:
self.assertIn(rel("DCIM", "100MEDIA", "DJI_0001.MP4") + ";300000;", fh.read())
# una copia que no queda igual que el original no se da por buena
malo = mk(tarjeta, "DCIM/100MEDIA/DJI_0002.MP4", 2026, 8, 10, tam=50_000)
real = O.hashlib.blake2b
vez = []
class Trucado: # la segunda lectura (la de comprobación) sale distinta
def __init__(self, **kw):
self.h = real(**kw)
vez.append(self)
self.malo = len(vez) % 2 == 0
def update(self, b):
self.h.update(b[::-1] if self.malo else b)
def digest(self):
return self.h.digest()
def hexdigest(self):
return self.h.hexdigest()
O.hashlib.blake2b = Trucado
try:
estado = {"bytes": 0}
destino = os.path.join(raiz, "_Entrada", "prueba", "DJI_0002.MP4")
with self.assertRaises(OSError):
O._copiar_comprobando(malo, destino, estado)
finally:
O.hashlib.blake2b = real
self.assertFalse(os.path.exists(destino) or os.path.exists(destino + ".copiando"), "dejó una copia sin comprobar")
self.assertEqual(estado["bytes"], 0)
def test_versiones_del_mismo_clip(self):
"""39 clips estaban dos veces con el mismo nombre y distinta calidad, y no cabían juntos en su mes."""
raiz = cliente("Versiones")
oct_, may = "2025/10- Octubre/00_Grabacion/BLACKMAGIC_MP4/", "2026/05- Mayo/00_Grabacion/BLACKMAGIC_MP4/"
buena = mk(raiz, oct_ + "A002_03020727_C001.mp4", 2025, 12, 1, tam=9000)
peor = mk(raiz, may + "A002_03020727_C001.mp4", 2026, 7, 1, tam=3000)
mk(raiz, oct_ + "C0001.MP4", 2025, 10, 5, tam=5000) # se llaman igual…
mk(raiz, may + "C0001.MP4", 2026, 5, 5, tam=4000) # …pero son clips distintos: duran distinto
# dos exportados con el mismo nombre no son un duplicado: son dos entregas de la pieza (la vertical y
# la horizontal). No son material de cámara, así que ni se leen: `sonda` fallaría si se los pasaran.
mk(raiz, "2026/02- Febrero/02- Liftera/Exportados/Liftera VR3 Final.mp4", 2026, 2, 20, tam=7000)
mk(raiz, "2026/02- Febrero/01- Otra/Exportados/Liftera VR3 Final.mp4", 2026, 2, 21, tam=6000)
p = plan(raiz)
self.assertTrue([f for f in p["filas"] if "OJO" in f["motivo"]], "sin comparar, las dos versiones chocan en su mes")
medidas = {buena: (12.0, 3840, 2160), peor: (12.02, 1920, 1080),
os.path.join(raiz, oct_.replace("/", S), "C0001.MP4"): (10.0, 1920, 1080),
os.path.join(raiz, may.replace("/", S), "C0001.MP4"): (25.0, 1920, 1080)}
def sonda(ruta):
d, an, al = medidas[ruta]
return {"codec": "h264", "perfil": "", "pix_fmt": "yuv420p", "ancho": an, "alto": al, "fps": 25.0, "dur": d,
"kbps": 1000, "alfa": 0, "acodec": "aac", "pistas_audio": 1}
viejos = (O.ffprobe, O.herramientas)
O.ffprobe, O.herramientas = sonda, lambda: {"ffprobe": {"ok": True}}
try:
self.assertEqual(O.duplicados(raiz, minimo=0)["por_comparar"], 2)
d = O.duplicados(raiz, minimo=0, calidades=True)
finally:
O.ffprobe, O.herramientas = viejos
self.assertEqual([(g["tipo"], g["queda"], g["mueven"]) for g in d["grupos"]], [("calidad", buena, [peor])], d["grupos"])
r = O.apartar_duplicados(raiz, d["grupos"])
self.assertEqual((r["movimientos"], r["errores"]), (1, []), r)
self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(buena), "Duplicados", "A002_03020727_C001.mp4")))
self.assertTrue(os.path.isfile(os.path.join(raiz, may.replace("/", S), "C0001.MP4")), "apartó un clip que solo se llamaba igual")
# ya no chocan: las dos van a su mes, la peor dentro de Duplicados
p = O.Plan(O.raiz_cfg(raiz)).correr()
self.assertFalse([f for f in p["filas"] if "OJO" in f["motivo"]], [f["motivo"] for f in p["filas"]])
aplicar(raiz, p)
mar = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion", "BLACKMAGIC_MP4")
self.assertTrue(os.path.isfile(os.path.join(mar, "A002_03020727_C001.mp4")), "\n".join(arbol(raiz)))
self.assertTrue(os.path.isfile(os.path.join(mar, "Duplicados", "A002_03020727_C001.mp4")), "\n".join(arbol(raiz)))
def test_orden_automatico(self):
"""La pasada automática hace lo seguro en cada carpeta, y se frena sola cuando no debe seguir."""
normal = cliente("Auto normal")
for n in ("C0001.MP4", "C0002.MP4"):
mk(normal, "2026/04- Abril/Grabacion/" + n, 2026, 4, 10)
mk(normal, "2026/04- Abril/01 - Botox/final.mp4", 2026, 4, 12) # un retoque de nombre: dudoso, no se toca
mucho = cliente("Auto demasiado")
for i in range(6):
mk(mucho, "2026/04- Abril/Grabacion/C%04d.MP4" % i, 2026, 4, 10)
todas, ajuste = O.CONFIG["raices"], dict(O.CONFIG["automatico"])
desconectada = {"ruta": os.path.join(TMP, "no-conectada"), "cliente": "Sin disco", "tipo": "contenido"}
O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto normal"] + [desconectada]
ensayo = os.path.join(TMP, "ensayo-tarea")
os.makedirs(ensayo, exist_ok=True)
os.environ["ORGANILAB_ENSAYO"] = ensayo
try:
res = O.automatico(origen="prueba")
self.assertEqual([(c["cliente"], c["estado"]) for c in res["carpetas"]], [("Auto normal", "con dudas"), ("Sin disco", "sin conexion")])
c = res["carpetas"][0]
self.assertEqual((c["movimientos"], c["dudosos"], c["errores"]), (2, 1, []), c)
self.assertTrue(os.path.isfile(os.path.join(normal, "2026", "04- Abril", "00_Grabacion", "SONY", "C0001.MP4")))
self.assertTrue(os.path.isdir(os.path.join(normal, "2026", "04- Abril", "01 - Botox")), "tocó lo dudoso")
self.assertEqual(O.estado_automatico()["ultimo"]["origen"], "prueba")
self.assertFalse(O.AUTO["activo"])
# con más movimientos que el límite, la carpeta no se toca y se dice por qué
O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto demasiado"]
O.CONFIG["automatico"]["limite"] = 5
c = O.automatico(origen="prueba")["carpetas"][0]
self.assertEqual(c["estado"], "demasiado", c)
self.assertTrue(os.path.isfile(os.path.join(mucho, "2026", "04- Abril", "Grabacion", "C0000.MP4")), "movió pese al límite")
# encenderlo da de alta la tarea de Windows (aquí, en una carpeta de ensayo) con la orden «automatico»
self.assertIn("error", O.configurar_automatico(True, "25:00"))
r = O.configurar_automatico(True, "03:30")
self.assertTrue(r.get("ok") and r["activo"] and r["hora"] == "03:30", r)
with open(os.path.join(ensayo, "tarea.json"), encoding="utf-8") as fh:
tarea = json.load(fh)
self.assertTrue(tarea["activo"] and tarea["hora"] == "03:30" and tarea["orden"].endswith(" automatico"), tarea)
self.assertFalse(O.configurar_automatico(False, "03:30")["activo"])
finally:
os.environ.pop("ORGANILAB_ENSAYO", None)
O.CONFIG["raices"] = todas
O.CONFIG["automatico"] = ajuste
if __name__ == "__main__":
try:
unittest.main(verbosity=2, exit=False)