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 }.confidenceranges 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
| Field | Merge rule |
|---|---|
tags, ids, metadata | Shallow merge: keys in the delta overwrite same-named keys in the bundle. Pre-existing keys not in the delta are preserved. |
mediaFiles | Match 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, errors | Append: 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
metadatafields the bundle should populate. - All bundle types and the helpers (
getBundleField,getBundleValue,getConfidentCanonicalIds,mergeConfidentFields) are exported from@aviato/plugin-sdk.