dec-0114

Vista generada de dec-0114: Escaneo e identificación por contenido como plugin sobre plugin-seams

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0114-scanner-por-contenido-como-plugin.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0114-scanner-por-contenido-como-plugin.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoLOCKED
Fecha2026-09-28
Ficherodocs/decisions/dec-0114-scanner-por-contenido-como-plugin.md

Enmienda a: r48

Enmendado o sustituido por: dec-0126, dec-0127, dec-0132

Por qué importa (del frontmatter del ADR):

Gobierna dónde y cómo vive el escaneo e identificación por contenido: como extension point scanner sobre plugin-seams (r48/r49), con huella de contenido calculada en el daemon vía range reads de SeekableMediaSource, confianza puntuada y explicada, y reescaneo incremental. Sin este ADR, un executor leería r48 L2 («scanner explícitamente FUERA») y no lo haría, o lo metería en el core de catalog-svc por ruta de fichero (el modelo de Jellyfin que queremos superar), o calcularía huellas leyendo bytes de vídeo en TS (viola r01).

Nodos del roadmap que lo citan en refs: track/plugin-seams/w2

Páginas de la documentación que lo citan: Plugins y fuentes (implementado), Servidores conectados y Jellyfin (especificado), Metadatos en el fichero (especificado), Modelo de datos (especificado), Importar NFO de Jellyfin (especificado), Activar los metadatos incrustados (especificado), Raíces y fuentes (especificado), Plugins (implementado), Visión (especificado), Primera biblioteca (especificado)

Texto del ADR

Leído de docs/decisions/dec-0114-scanner-por-contenido-como-plugin.md, el fichero canónico.

dec-0114 — Escaneo e identificación por contenido como plugin sobre plugin-seams

  • Fecha: 2026-09-28
  • Estado: LOCKED
  • Enmendado por dec-0126 y dec-0127 (LOCKED juntos, 2026-10-02):
    • §3: la huella se calcula sobre el payload (mdat / Clusters) más facts en lista blanca, no sobre las ventanas de cabeza y cola del fichero. "Tamaño exacto" pasa a payloadSize, el hash completo opcional pasa a ser del payload, y el OpenSubtitles se toma antes de la primera escritura (dec-0126 §3.3). Para árboles, imágenes y ficheros firmados, la huella de asset va por layout (lock de dec-0127).
    • §5: el ledger de escaneo guarda además metadataHash y el estado del documento (los tres punteros del lock de dec-0127, P2). Una fingerprintVersion nueva no cambia el AssetId (assetIdVersion: 1, dec-0127 P6).
  • Decisor: waxin, vía AskUserQuestion (2026-09-28): escaneo/identificación por contenido: SÍ, como PLUGIN sobre plugin-seams (r48/r49, packages/plugin-sdk, packages/plugins/*).
  • Enmienda: r48 L2 («Scanner explícitamente FUERA (discovery→identification→mutation = otra dimensión; sprint posterior)»). El scanner deja de estar fuera: este ADR es ese sprint posterior.
  • Cita: r48 §3.2/§3.3 (manifest, registry, precedencia, MetadataPatch declarativo), r49 (precedencia del manifest), r04 (SeekableMediaSource, ReadPurpose), r01 (bytes de vídeo nunca por JS), r08 (catálogo Work → Edition → MediaAsset → SourceBinding), r18/r20 (contract-first Arktype, bus cmd/qry/evt), r28 §3 (anti-especulativo), dec-0112 (SCAN-01), dec-0110 (MediaEngine.probe).
  • Numeración: el brief reservaba dec-0112; en la numeración final de las tandas 2/3 este ADR queda en dec-0114 (dec-0112 = gate WC-JF12, dec-0113 = identity token model). Tabla completa en la cabecera de dec-0110.

Enmendado por dec-0132 (LOCKED, 2026-10-02): libros, cómics y música dejan de estar fuera y se abordan con plugins content-type. La huella de §3 se generaliza por layout (fichero, árbol o imagen; dec-0132 §4.1). IdentificationProposal.kind es un WorkKindId registrado.

Contexto

  1. Hoy catalog-svc escanea por ruta: CatalogHandler.scanAsset recibe un path absoluto, ffprobe (vía workers-svc) → MediaIndex → DB → evento. La identidad del asset es la ruta; un rename o un move es un asset nuevo. Es el modelo de Jellyfin (y su punto débil con carpetas desordenadas y mal nombradas).
  2. r48 L2 sacó el scanner del sprint de 4 módulos porque discovery → identification → mutation era «otra dimensión». La wave 1 de track/plugin-seams (w1) dejó el seam hecho: manifest, registry con precedencia explícita, lifecycle, health, y dos consumidores reales (source-local, metadata-local-nfo). PluginKind hoy: metadata | search | source | seed.
  3. El diferencial nº 6 de jellyfin12-design (escaneo inicial ≤ 1/3, reescaneo sin cambios < 5 s, ≥ 3x menos errores de identificación sobre corpus mal nombrado) sólo es alcanzable si la identidad es de contenido y el reescaneo es incremental.

Qué se decide

1. Reparto de responsabilidades: core orquesta, plugin identifica, nadie muta en directo

EtapaQuiénQué
Discoverycore (orquestador de escaneo del catálogo)enumera entradas de cada SourceBinding vía la capability de enumeración de la fuente; mantiene el ledger de escaneo; decide qué hay que (re)procesar
Huelladaemon Zig, pedida por el corerange reads sobre SeekableMediaSource con purpose: 'probe' + MediaEngine.probe (dec-0110); devuelve digests y facts, nunca bytes
Identificaciónplugin(s) scannerpuntúa candidatos a partir de huella + facts + señales (§4) y devuelve propuestas con evidencia
Mutacióncoreaplica propuestas como MetadataPatch/binding con la precedencia de r48 §3.2; el plugin nunca escribe
  • La etapa de huella vive en el daemon por r01: la cabecera y la cola de un fichero de vídeo son bytes de vídeo. El plugin TS recibe hashes y facts; no ve el payload.

2. El seam: nuevo extension point scanner

  • Se añade scanner a PluginExportsByKind en packages/plugin-sdk con su interfaz (IScannerProvider, nombre final en el ticket) en el mismo cambio que su primer consumidor real, como exige el propio manifest.ts («imposible registrar un kind sin interfaz»).
  • Contrato (forma; los nombres exactos los fija el ticket):
    • identify(input) → Result<IdentificationProposal[], ResultError<'SCANNER_IDENTIFY_FAILED'>> donde input = { locator, sourceBindingId, fingerprint, containerFacts, hints } y hints son señales baratas del core (nombre de fichero, ruta relativa, sidecars presentes).
    • IdentificationProposal = { target: WorkRef | EditionRef | externalIds, kind: movie | episode | extra | …, confidence: 0..1, evidence: Evidence[], providerIdentity }; cada Evidence = { signal, value, weight, contribution } para que la puntuación sea reproducible y explicable.
    • Opcional match(fingerprint) → known asset? para plugins con base de huellas propia.
  • Registry, lifecycle, health, timeout por provider, ctx.config/ctx.secrets e identidad de observabilidad son los de w1, sin variantes: un scanner caído degrada la identificación, no el escaneo (el asset entra como «no identificado»).
  • Contratos de bus (contract-first Arktype en @styx/api-contracts): comandos y eventos de escaneo (cmd.catalog.scan…, evt.catalog.asset…) se definen en el ticket; este ADR sólo fija que la orquestación es del core y que las propuestas viajan como datos, no como mutaciones.

3. Huella de contenido

  • Componentes (calculados en el daemon, versionados como fingerprintVersion):
    • tamaño exacto;
    • hash de una ventana de cabeza y una ventana de cola (range reads acotados; tamaños fijados en el ticket, del orden de 64 KiB), con BLAKE3;
    • hash compatible OpenSubtitles (64 KiB cabeza + 64 KiB cola + tamaño), útil como señal frente a bases externas;
    • digest de facts de contenedor (MediaEngine.probe: duración, pistas, códecs, extradata) — estable ante remux del mismo contenido a otro nombre, distinto ante recodificación;
    • hash completo del fichero opcional y perezoso, a prioridad background-cache, sólo para deduplicación exacta.
  • Coste acotado: la huella lee del orden de cientos de KiB por fichero (el spike de dec-0109 midió ~66 KB de mediana para los facts), nunca el fichero entero en el camino de escaneo.
  • La huella es identidad del contenido, no de la ruta: el mismo contenido en otra ruta o con otro nombre se re-vincula (SourceBinding nuevo sobre el mismo MediaAsset) sin reidentificar.

4. Confianza puntuada y explicada

  • Señales (cada plugin declara cuáles usa): parse de nombre y estructura de carpetas, sidecar NFO (autoridad local), tags embebidos en el contenedor, coincidencia de duración con el runtime del candidato, idiomas de pistas, coincidencia de huella en el ledger local y, opcionalmente, bases externas por hash (desactivadas en benchmark, igual que en Jellyfin; dec-0112).
  • Umbrales: ≥ τ_auto se aplica solo; entre τ_review y τ_auto va a cola de revisión; por debajo, «no identificado». Los umbrales se calibran sobre el corpus etiquetado de SCAN-01 (300 casos mal nombrados), no se fijan a ojo.
  • Precedencia (r48 §3.2): manual lock > autoridad local > orden configurado > confianza > fallback. Un scanner nunca pisa un lock manual ni una edición del usuario.
  • Toda propuesta aplicada guarda su evidencia como provenance del patch (r48 §3.3): «por qué este fichero es esta película» es consultable.

5. Reescaneo incremental

  • El ledger de escaneo (core) guarda por entrada: (sourceBindingId, locator), tupla barata de cambio de la fuente (tamaño + mtime + inode/etag según la capability), fingerprintVersion, huella, resultado de identificación y versión del plugin que la produjo.
  • Reescaneo: enumerar → comparar tupla barata → sólo las entradas cambiadas o nuevas piden huella; las desaparecidas se marcan (no se borran de inmediato: un move aparece como desaparición + alta con la misma huella y se resuelve como re-vínculo).
  • Cambiar la versión de un plugin o de la huella invalida sólo lo que ese cambio afecta (reidentificar sin re-hashear si la huella no cambió).
  • Objetivo verificable: reescaneo sin cambios de 10k ficheros < 5 s (umbral de SCAN-01).

6. Gate: WC-JF12-SCAN-01 (bajo dec-0112)

Objetivos pre-registrados v1, con las reglas de dec-0112 (mismo host, corpus por hash de clips mínimos válidos, proveedores remotos desactivados en ambos lados, medición tras la migración de la 12.0, 5 corridas, IC95):

  • escaneo inicial de 10k ficheros: R ≤ 1/3 del tiempo de Jellyfin;
  • reescaneo sin cambios: U < 5 s;
  • identificación: errores R ≤ 1/3 de los de Jellyfin sobre los 300 casos etiquetados;
  • move/rename de 1k ficheros: U = 0 reidentificaciones y 0 pérdidas de estado de usuario (visto, progreso) — Jellyfin se reporta (I).
  • Sin afirmaciones frente a Plex (no está en el harness).

Alternativas rechazadas

  • Mantener el scanner fuera (r48 L2). Rechazada por waxin.
  • Scanner en el core de catalog-svc. Rechazada: la identificación es heurística y plural (NFO, nombres, bases externas, dominios como anime o música); es exactamente lo que el seam de plugins existe para alojar con precedencia y health explícitos.
  • Plugin que muta el catálogo directamente. Rechazada por r48 §3.3 (patch declarativo, merge del core).
  • Huella calculada en TS leyendo el fichero. Rechazada por r01; además duplicaría la ruta de lectura que el daemon ya optimiza (caché por niveles, cancelación).
  • Hash completo del fichero como identidad. Rechazada como identidad primaria: lee el fichero entero (inviable en fuentes remotas y en 10k ficheros); queda como señal opcional perezosa.
  • Identidad por ruta con detección de renames por heurística de nombre. Rechazada: es el modelo que falla con carpetas desordenadas.

Consecuencias

  • track/plugin-seams gana una wave nueva (propuesta w2, después de cerrar w1) con el kind scanner, un plugin first-party y el orquestador de escaneo del core.
  • El daemon gana una operación de huella (sobre MediaEngine.probe de dec-0110); hasta que el motor exista, la huella puede empezar con los hashes de ventana (que no necesitan demux) y añadir el digest de facts cuando aterrice el probe.
  • CatalogHandler.scanAsset por ruta pasa a ser un caso particular (una entrada del ledger) del escaneo por SourceBinding.
  • La propuesta de milestones está en docs/track/plugin-seams/plans/scanner-plugin-proposal.md.

Lo que este ADR NO decide

  • No crea nodos ni milestones en styx.model.yml (gobernanza posterior).
  • No fija nombres exactos de interfaces, comandos y eventos: los fija el ticket con su consumidor.
  • No decide proveedores externos (TMDb, AniDB, bases de huellas) ni su política de red; sólo que son señales opcionales y que en benchmark van desactivadas.
  • No decide el watcher en tiempo real (inotify/fsevents): el reescaneo incremental es suficiente para el gate; un watcher es optimización posterior.
  • No cambia el modelo del catálogo (r08): sólo cómo se llega a sus entidades.
  • No aborda libros, cómics ni música como tipos de Work.

Back-refs

  • r48 L2 lleva banner de enmienda apuntando aquí (el mismo banner cita dec-0112 para L4).
  • Relacionados: dec-0112 (SCAN-01 bajo sus reglas), dec-0110 (MediaEngine.probe).