Filesystem Capability
How a plugin discovers files for libraries to ingest, and optionally watches for changes and exposes local paths.
A filesystem plugin tells Aviato where files live and how to
enumerate them. It is the only thing standing between "the user
added a library" and "the pipeline has files to process."
The bundled aviato-fs-local plugin handles the local-disk case.
Cloud and network filesystem providers (S3, Dropbox, SMB, etc.)
are implemented as their own filesystem plugins. Anything you
can list, fetch, and watch can be a filesystem plugin.
Manifest
{
"id": "aviato-fs-local",
"name": "Local Filesystem",
"version": "1.0.0",
"description": "Add local files to your libraries",
"author": "Aviato",
"license": "MIT",
"engine": "bun",
"entry": "src/index.ts",
"aviato": { "minVersion": "0.1.0" },
"capabilities": ["filesystem"],
"mediaTypes": ["movies", "tv", "music", "photos", "books"],
"configuration": [
{ "key": "excludePatterns", "label": "Exclude patterns", "input": "string-list" },
{ "key": "watchForChanges", "label": "Watch for changes", "input": "toggle", "default": true }
],
"capabilityConfig": {
"filesystem": {
"supportsWatch": true,
"supportsLocalFileAccess": true,
"supportsWrite": true
}
}
}mediaTypes lists the library types that can use this
filesystem. Most filesystem plugins accept everything (the
medium doesn't care what's on it); a niche provider could narrow
this.
capabilityConfig.filesystem declares which optional behaviors
the plugin implements:
| Flag | Meaning | Effect |
|---|---|---|
supportsWatch | filesystem.watch is implemented | Library config exposes a "Watch for changes" toggle. |
supportsLocalFileAccess | filesystem.getLocalPath returns a real path on disk | Lets Aviato's streaming pipeline use the file directly with FFmpeg/FFprobe instead of streaming bytes. |
supportsWrite | The plugin can persist user uploads | Lets users upload subtitles, posters, etc. to libraries backed by this filesystem. |
A cloud provider like S3 would set supportsWatch: true (via S3
events), supportsLocalFileAccess: false, and
supportsWrite: true. A read-only network share would set all
three to false.
RPC contract
Methods Aviato calls on a filesystem plugin (filesystem.*
namespace, types exported from @aviato/plugin-sdk):
| Method | Optional? | Purpose |
|---|---|---|
validate | required | Check that the user-supplied config (paths, credentials) is valid before saving a library. |
scan | required | Enumerate every file under the configured location. |
watch | optional | Subscribe to filesystem changes and emit events as they happen. |
unwatchLibrary | optional | Stop watching a single library. |
unwatch | optional | Stop watching everything (server shutdown). |
getLocalPath | optional | Translate a uri returned during scan into a real path on disk. |
Optional methods are wired up only if the SDK's
FilesystemHandlers object includes them. The SDK lazily
registers each RPC handler.
validate
Quick correctness check. Called when the user creates or edits a library.
validate: async (config) => {
const path = config.path as string
if (!path) {
return { valid: false, errors: ['Path is required'] }
}
try {
await fs.access(path)
return { valid: true }
} catch {
return { valid: false, errors: [`Cannot access ${path}`] }
}
}Returns { valid: boolean, errors?: string[] }. The wizard
refuses to save a library with valid: false and surfaces the
errors inline.
scan
Enumerate the configured location once. Aviato calls this when a library is first created and on every subsequent manual scan.
scan: async (config, extensionMap, emitters) => {
const seen = new Set<string>()
for await (const entry of walk(config.path as string)) {
if (!matchesExtensionMap(entry, extensionMap)) continue
seen.add(entry.path)
emitters.emitFile({
uri: `file://${entry.path}`,
filename: entry.name,
size: entry.size,
mimeType: entry.mimeType,
modifiedAt: entry.mtime.toISOString(),
})
}
// Files that were previously known but didn't show up this scan
await emitRemovedFiles(seen, emitters)
emitters.emitScanComplete()
return {
totalFiles: seen.size,
newFiles: 0, // tracked by Aviato based on what it had before
modifiedFiles: 0,
removedFiles: 0,
errors: [],
durationMs: Date.now() - start,
}
}extensionMap is Aviato's compiled view of which extensions and
glob patterns the active libraries care about, so the plugin
can skip files it knows Aviato won't use:
type ExtensionMap = {
primary: string[] // primary file extensions
auxiliaries: Record<string, {
extensions: string[]
patterns?: string[]
}> // companion files by role
}emitters is how the plugin streams discoveries back to Aviato.
The SDK creates these for you:
| Emitter | Notification it sends | Use when |
|---|---|---|
emitFile(file) | filesystem.file | A file was discovered (new or unchanged). |
emitFileRemoved(uri) | filesystem.file.removed | A previously-known file is gone. |
emitScanComplete() | filesystem.scan.complete | Final notification, flushes Aviato's batch. |
Streaming via emitters is preferred over returning a giant array. The ingestion pipeline can start probing files while the scan is still running, and a 2-million-file scan won't hold a 2-million-entry response in memory.
The return value is a summary used for the admin UI scan history.
watch
Optional. When supportsWatch is true, Aviato calls this once
per library after the initial scan. The plugin keeps a watcher
alive and emits events through the same emitters object as
scan.
watch: async (config, extensionMap, libraryId, emitters) => {
const watcher = chokidar.watch(config.path as string, { ignoreInitial: true })
watcher.on('add', p => emitters.emitFile(toDiscoveredFile(p)))
watcher.on('change', p => emitters.emitFile(toDiscoveredFile(p)))
watcher.on('unlink', p => emitters.emitFileRemoved(`file://${p}`))
watchers.set(libraryId, watcher)
}Watch is fire-and-forget from Aviato's perspective. The call
returns immediately and the watcher runs until
unwatchLibrary(libraryId) or unwatch() tears it down.
unwatchLibrary / unwatch
Stop watching a specific library, or every library. Called when a library is deleted, the plugin is being stopped, or Aviato is shutting down.
unwatchLibrary: async (libraryId) => {
await watchers.get(libraryId)?.close()
watchers.delete(libraryId)
},
unwatch: async () => {
for (const w of watchers.values()) await w.close()
watchers.clear()
}getLocalPath
Optional. When supportsLocalFileAccess is true, Aviato's
streaming pipeline calls this to convert a plugin-emitted uri
into a real path on the local filesystem. The streaming server
uses the path to spawn FFmpeg/FFprobe without buffering the file
through the JSON-RPC channel.
getLocalPath: async (uri) => {
// file://... -> /...
return new URL(uri).pathname
}A cloud provider that doesn't have a local copy should leave
this method off and supportsLocalFileAccess: false. The
streaming pipeline will fall back to streaming bytes through the
plugin (when that path exists).
Discovered files
Every file the plugin emits is a DiscoveredFile:
{
uri: string // opaque, plugin-defined; must round-trip through getLocalPath
filename: string // basename only
size: number // bytes
mimeType?: string // optional, sniff if cheap
modifiedAt: string // ISO 8601
metadata?: Record<string, unknown> // free-form (e.g. ETag, version, region)
}uri is opaque to Aviato. Use whatever scheme makes sense for
your provider (file://..., s3://bucket/key, dropbox://...).
The pipeline treats it as a stable key: if the same uri shows
up across two scans, it's considered the same file (with
modifiedAt deciding whether to re-probe).
SDK shape
import type { FilesystemHandlers } from '@aviato/plugin-sdk'
import { createPlugin } from '@aviato/plugin-sdk'
const filesystem: FilesystemHandlers = {
validate: async (config) => { /* ... */ },
scan: async (config, extensionMap, emitters) => { /* ... */ },
watch: async (config, extensionMap, libraryId, emitters) => { /* ... */ },
unwatchLibrary: async (libraryId) => { /* ... */ },
unwatch: async () => { /* ... */ },
getLocalPath: async (uri) => { /* ... */ },
}
createPlugin({ filesystem })The SDK only registers an optional method (watch,
unwatchLibrary, unwatch, getLocalPath) when the
corresponding handler is present. Forgetting the matching
capabilityConfig.filesystem.supports* flag is the difference
between "Aviato knows it can call this" and "the plugin will
respond to the call." Both sides need to agree.
See also
- Plugin manifest
- Plugin system overview
- Bundle covers the data structure built from discovered files.
- All filesystem types (
DiscoveredFile,ScanResult,ValidationResult,FilesystemHandlers,FilesystemEmitters) are exported from@aviato/plugin-sdk. aviato-fs-local: reference implementation for the local-disk case.