AviatoAviato
DeveloperPlugins

The Bundle

The data structure that flows through the ingestion pipeline. Hooks operate on it; Aviato persists what the pipeline finishes with.

A Bundle is the in-flight representation of a library item being ingested. It starts life as a BundleFiles (just the files a filesystem plugin emitted), accumulates probe data as it moves through the pipeline, gets enriched by indexers and hook plugins, and ends as the persisted record Aviato writes to the database.

If you write a hook plugin, you spend most of your time reading the in-flight Bundle and returning a BundleDelta to merge into it. If you write an indexer, you populate metadata, ids, entities, and assets in your IndexResult, and Aviato merges those into the bundle before the next hook runs.

All bundle types are exported from @aviato/plugin-sdk.

Top-level shape

type Bundle = {
  files:      BundleFiles                 // required, set at scan time
  tags?:      Record<string, string>      // free-form key/value (file-derived tags)
  ids?:       Record<string, IdValue>     // canonical IDs: imdb, tmdb, mbid, ...
  metadata?:  BundleMetadata              // typed canonical fields
  assets?:    BundleAsset[]               // artwork: posters, fanart, banners
  subtitles?: BundleSubtitle[]            // external + embedded subtitle streams
  entities?:  BundleEntity[]              // related people, shows, seasons, ...
  chapters?:  BundleChapter[]             // chapter markers
}

files is required because every other field is optional enrichment of something. Without files there's nothing to enrich.

files

type BundleFiles = {
  media:     BundleMediaFile[]      // primary, trailer, extra, behind-the-scenes, ...
  auxiliary: BundleAuxiliaryFile[]  // companion sidecars (.nfo, .srt, posters)
}

BundleMediaFile

{
  id?:         string         // assigned by Aviato once persisted; absent before that
  uri:         string         // opaque, set by the filesystem plugin
  path:        string         // absolute or provider-specific path
  filename:    string
  extension:   string
  size:        number
  mimeType?:   string
  modifiedAt?: string
  type:        string         // 'primary' | 'trailer' | 'extra' | 'behind-the-scenes' | ...
  edition?:    string         // 'Extended Cut', 'Director's Cut'
  partNumber?: number         // for multi-part items (CD1, CD2)
  description?: string
  tags?:       Record<string, string>  // free-form
  fileInfo?:   FileInfo       // ffprobe output (streams, duration, container)
  localPath?:  string         // absolute path on the server's disk if available
}

type is whatever the library plugin's path rules classified the file as (see library.mdx). Hooks can edit type, edition, partNumber, and description via BundleMediaFileDelta. Identifying fields like uri are immutable.

BundleAuxiliaryFile

{
  path:         string
  extension:    string
  sourcePlugin: string  // which hook surfaced this file
}

Sidecars are tracked but not stored as items. Aviato uses them as provenance for assets, subtitles, and metadata that originated outside the primary file.

ids

A free-form map from provider name to ID:

type IdValue =
  | true                                              // "this provider applies, no ID yet"
  | { id: string, confidence?: number, url?: string }
ids: {
  imdb: { id: 'tt1234567', confidence: 0.92, url: 'https://imdb.com/title/tt1234567' },
  tmdb: { id: '603' },
  mbid: true,                  // sentinel: eligible for MusicBrainz lookup, no ID resolved
}

Two patterns:

  • Strong identification, when the indexer found a confident match: return { id, confidence }. confidence ranges 0–1.
  • Sentinel (true): used when a hook wants to flag eligibility for a later lookup but has no ID itself. The downstream indexer reads the sentinel and resolves it.

Helper utilities exported from @aviato/plugin-sdk:

  • getConfidentCanonicalIds(bundle, threshold = 0.8) returns only confidently identified IDs.
  • mergeConfidentFields(...) merges fields from multiple sources giving preference to ones with higher confidence.

metadata

type BundleMetadata = {
  title?:        string
  originalTitle?: string
  year?:         number
  season?:       number
  episode?:      number
  duration?:     number       // seconds
  genres?:       string[]
  overview?:     string
  rating?:       number
  // free-form: any other keys are accepted via .catchall(z.unknown())
}

The schema uses .catchall(z.unknown()), so plugins can attach arbitrary extra keys. The library plugin's itemSchema decides which keys are visible in the UI and editor; anything not declared in the schema is persisted but inert.

getBundleField and getBundleValue (also exported from @aviato/plugin-sdk) dig values out by key, including from nested file-info streams.

assets

Artwork: posters, fanart, banners, thumbnails. One asset per row.

type BundleAsset = {
  type:         string   // 'poster' | 'fanart' | 'banner' | 'thumb' | 'logo' | ...
  uri?:         string   // remote URL Aviato should download
  path?:        string   // local file Aviato should ingest
  source:       string   // plugin id that produced it
  mimeType?:    string
  mediaFileId?: string   // attach to a specific file (e.g. season-specific poster)
}

A bundle can carry many assets of the same type. Aviato picks one to display per slot using the renderer's prefer: ordering. Library plugins declare which types feed which slots in their itemRenderer.slots and entityRenderers[type].slots config.

subtitles

Both external sidecars and embedded streams flow through the same array:

type BundleSubtitle = {
  type:        'external' | 'embedded'
  language?:   string     // BCP-47 / ISO 639
  format:      string     // 'srt' | 'vtt' | 'ass' | 'ssa' | 'pgs' | ...
  path?:       string     // for external
  streamIndex?: number    // for embedded: index in the source container
  source:      string     // plugin id
  mediaFileUri?: string   // file the subtitle attaches to
}

The streaming pipeline reads this array when building HLS playlists. Embedded subtitles surfaced by embedded-metadata and external sidecars discovered by external-subtitles end up in the same shape; downstream consumers don't need to special-case them.

entities

Related records that exist in the entity graph: people, shows, seasons, albums, genres. Every entity has a role: the same person can be actor on one item and director on another.

type BundleEntity = {
  role:      string                          // 'actor' | 'director' | 'show' | 'season' | 'genre' | ...
  name:      string
  status:    'complete' | 'pending'          // 'pending' = referenced but not yet enriched
  metadata?: Record<string, string>          // free-form (character name, billing order)
  ids?:      Record<string, IdValue>         // canonical IDs for this entity
  source:    string                          // plugin id
}

Aviato reconciles entities into the entity graph on persist: same (role, ids) resolves to the same record. status: 'pending' lets a pipeline plugin declare an entity exists without resolving it; a later indexer pass can fill it in. See library.mdx for how entities get rendered.

chapters

Chapter markers for media that has them: video chapters, audiobook chapters, EPUB chapters.

type BundleChapter = {
  mediaFileUri?: string                      // which file in the bundle
  mediaFileId?:  string                      // host-resolved id (don't set this from a plugin)
  startTime:     number                      // seconds for media; 1-indexed pages for ebooks
  endTime?:      number | null
  title?:        string | null
  role?:         'intro' | 'credits' | 'chapter' | 'scene' | null
  metadata?:     Record<string, unknown> | null
}

For ebooks, fractional startTime is allowed (e.g. EPUB CFI subdivisions). Plugins should set mediaFileUri rather than mediaFileId: file IDs are not stable across scans; the persistence layer resolves the URI to the current file row.

tags

Free-form key/value bag for things that don't fit elsewhere: file-level container tags, NFO custom fields, anything a plugin wants to round-trip without claiming it's typed metadata.

tags: {
  encoder: 'x265',
  rip-source: 'BluRay',
  'show:tvdbId': '12345',
}

Aviato persists them as JSON. Library plugins can read them in their getItemSummary / getItemDetail projections to surface them where they belong.

BundleDelta: what hooks return

Hooks return a partial bundle that Aviato merges into the in-flight bundle before passing it to the next subscriber:

type BundleDelta = {
  tags?:       Record<string, string>        // shallow-merged
  ids?:        Record<string, IdValue>       // shallow-merged
  metadata?:   Partial<BundleMetadata>       // shallow-merged
  mediaFiles?: BundleMediaFileDelta[]        // matched by uri, shallow-merged per entry
  assets?:     BundleAsset[]                 // appended
  subtitles?:  BundleSubtitle[]              // appended
  entities?:   BundleEntity[]                // appended
  chapters?:   BundleChapter[]               // appended
  errors?:     string[]                      // appended (plugin-reported soft errors)
}

Returning null from a hook is the pass-through, the same as returning an empty object.

Merge semantics

FieldMerge rule
tags, ids, metadataShallow merge: keys in the delta overwrite same-named keys in the bundle. Pre-existing keys not in the delta are preserved.
mediaFilesMatch by uri, then shallow-merge fields onto the existing BundleMediaFile. Cannot add or remove media files; the filesystem plugin owns that decision.
assets, subtitles, entities, chapters, errorsAppend: the delta entries are concatenated onto the bundle's array.

BundleMediaFileDelta is a narrow subset:

type BundleMediaFileDelta = {
  uri:          string                        // identifies the target file (required)
  type?:        string
  edition?:     string
  partNumber?:  number
  description?: string
  tags?:        Record<string, string>
  fileInfo?:    FileInfo                      // typically only the probe hook sets this
}

Worked examples

embedded-metadata reads ffprobe output and attaches container tags

plugin.hooks.on('pipeline.probe.afterProcess', async ({ bundle }) => {
  const primary = bundle.files.media.find(f => f.type === 'primary')
  if (!primary?.fileInfo) return null

  return {
    tags: extractContainerTags(primary.fileInfo),
    metadata: {
      duration: primary.fileInfo.duration,
    },
  }
})

posters attaches a sidecar poster

plugin.hooks.on('pipeline.probe.afterProcess', async ({ bundle }) => {
  const folder = dirname(bundle.files.media[0].path)
  const found  = await findPoster(folder)   // poster.jpg | folder.jpg | cover.jpg
  if (!found) return null

  return {
    assets: [{
      type: 'poster',
      path: found,
      source: 'aviato-posters',
    }],
  }
})

aviato-tmdb returns identification (an indexer, not a hook)

indexer: {
  index: async ({ file, options }) => ({
    success: true,
    metadata: {
      title: candidate.title,
      fields: { overview: candidate.overview, releaseDate: candidate.release_date },
      canonicalIds: [{ provider: 'tmdb', id: String(candidate.id) }],
      artwork:      [{ type: 'poster', url: posterUrl(candidate.poster_path) }],
      entities:     candidate.credits.cast.map(toPersonEntity),
    },
  })
}

Aviato translates the indexer's LibraryItemMetadata into bundle fields (metadata, ids, assets, entities) before the next hook (pipeline.index.afterProcess) sees the updated bundle.

See also

  • Hooks, events, and views covers when each hook fires.
  • Indexer capability describes the other producer of bundle fields, via IndexResult.
  • Library capability declares the typed metadata fields the bundle should populate.
  • All bundle types and the helpers (getBundleField, getBundleValue, getConfidentCanonicalIds, mergeConfidentFields) are exported from @aviato/plugin-sdk.

On this page