Import / Export
Move a lineup — packages, channels, and filters — between Airwave instances.
Export every package + channel + filter from one Airwave instance as a portable JSON file, then import it into another to recreate the same lineup — without touching anything instance-specific.
Overview
Airwave's lineup lives in three related tables: ChannelPackage (the top-level groupings), Channel (the numbered channels), and each channel's ChannelDefinition rows (the filters that decide what plays). Import/Export moves that authoring — packages, channels, and their filters — between two instances. It is the "share a lineup" / "migrate to a new box" feature for a self-hosted, source-available service.
What deliberately does not travel is everything an instance rebuilds locally:
- the media-source binding (which Plex/media server a channel resolves against — the target picks its own),
- the materialized schedule (regenerated on the target from the channel's filters),
- the media metadata cache (
MediaItemrows — repopulated by the target's own sync), - the schedule-pass cursor / horizon state (per-instance scheduling bookkeeping).
The reason: those are all functions of this server's Plex connection and clock. A filter like "genre = Comedy on the Movies library" is portable; the specific ratingKeys it matched on the source server are not. So the export carries the intent (field+value filters, library-by-name) and the target re-resolves it against its own Plex at import time.
History: see CHANGELOG.md (v0.9.16 → v0.9.20).
Export
Source: packages/api/src/services/transfer/export.ts (exportLineup), surfaced by the transfer.export tRPC query in packages/api/src/routers/transfer.ts. The web upload/download page is apps/web/src/routes/_auth/settings/transfer/index.tsx, which serializes the query result to airwave-lineup-<date>.json.
The exported object (LINEUP_EXPORT_VERSION = 1) is:
{
version, exportedAt,
packages: [{ key, name, description, icon, tint, sortIndex }],
channels: [{
number, name, callsign, description, icon, tint, enabled,
sortIndex, ordering, sortField, sortDir, bumperMode,
packageKey, // ← how a channel names its package
definitions: [{
kind, mode, sortIndex,
plexFilter, // field+value filter tree (portable)
plexLibraryTitle, // ← library by NAME, not key
plexCollectionKey, plexPlaylistKey, manualItemKeys // per-server ids
}]
}]
}Two reference-survival tricks make it portable across servers:
- Channel → package by
key. A channel carriespackageKey(c.package?.key), never a database id. Package keys are stable, human-meaningful slugs, so the importer can match/reuse a package by key on the target (export.ts:56). - Definition → library by title. Library keys differ per server, so the exporter looks up each definition's
plexLibraryKeyin the source'sMediaLibrarytable and writes the human title instead (plexLibraryTitle,export.ts:63). The importer remaps that title back to a key on the target.
What's omitted is enforced simply by what exportLineup selects — there is no mediaSourceId, no schedule, no media-cache, no cursor in the output. plexCollectionKey / plexPlaylistKey / manualItemKeys are still written "for completeness," but as the file header notes they reference per-server ids and the importer drops them (see PREDICATE-only below).
Import — staging
Before anything is written, an upload is annotated against this instance so the admin can pick and choose. This is read-only.
Flow (all in apps/web/src/routes/_auth/settings/transfer/):
index.tsxparses the uploaded JSON client-side, callstransfer.importPreview(a mutation, not a query, so the file rides the POST body rather than a length-limited URL —transfer.ts:65), and stashes the parsed file + preview in a module singleton (apps/web/src/features/transfer/staging.ts). That singleton survives SPA navigation but not a hard refresh — so the preview page redirects home when it's empty.import-preview.tsxrenders the pick-and-choose grid: oneFramePaneltile per package, each with a package-level select-all toggle over its channels, and a per-channel toggle row.
The preview payload is built by previewImport in packages/api/src/services/transfer/import.ts. Per channel it computes a ChannelPreview with these warning flags (rendered as an amber hover-card in ChannelRow, import-preview.tsx:186):
droppedKinds— non-PREDICATE definitions (collection/playlist/manual) that won't survive import.willBeDisabled— no PREDICATE definition remains, so the channel would have an empty pool and imports disabled.numberInUse— the channel's number already exists here, so it'll be reassigned.libraryUnmatched— a filter targets aplexLibraryTitlethis instance doesn't have; it'll fall back to searching all libraries.duplicate— an identical channel already exists here (same content signature); it'll be skipped on import.
The preview also reports whether the target source is ready (enabled, has a base URL, and has synced media items — import.ts:273) and whether the file's version is supported. The staging screen gates the Import button on a ready source and on the workflow engine being available (transfer.importAvailable). Duplicates default their toggle off and can't be selected (they're skipped regardless), so re-importing the same file is opt-in per channel rather than a wall of no-ops (import-preview.tsx:35).
Import — rules
The deterministic plan is built by planImport (import.ts:335) — reads only, no writes — and applied by executePackagePlan / executeChannelPlan. Three rules govern portability:
PREDICATE-only. Only kind === "PREDICATE" definitions are imported. Collection/playlist/manual definitions reference per-server ids that mean nothing on the target, so they're filtered out (import.ts:365, and flagged as droppedKinds in staging). PREDICATE filters are field+value trees, matched by title against the target's Plex, so they carry across.
Disabled-on-no-predicate. If stripping non-portable definitions leaves a channel with zero PREDICATE definitions, it has nothing to schedule. It still imports (name, number, appearance preserved) but with enabled = false (plan.disabled, applied at import.ts:587). Staging warns via willBeDisabled.
Number collision → probe upward. Channel numbers are preserved when free, otherwise bumped to the next free number, assigned in two passes over the channels being created (import.ts:400):
- Pass 1 — any channel whose original number isn't already taken keeps it (processing ascending original number, so lower numbers claim their spot first).
- Pass 2 — collisions probe upward: start at
originalNumber + 1and increment past every reserved number.
The reserved set starts from the numbers already on the instance and grows as the plan assigns, so assignments are unique in-memory across the whole run — even in dry-run, so the reported numbers are accurate. Only channels that will actually be created consume numbers; duplicates and non-selected channels don't. Packages follow a parallel rule: reuse an existing package by key, else create one preserving the file's key (not a fresh slug, unlike the AI generator — executePackagePlan, import.ts:455).
Dedup
Idempotency is James's explicit design call: drop the same file twice and the second import is a no-op.
Channels dedupe by a content signature (signatureParts, import.ts:91) built from:
(name, package key, ordering, sortField, sortDir, canonicalized plexFilter(s))Notably it excludes the channel number and all appearance-only fields (icon, tint, description, callsign). Identical content is "the same channel" even if its number differs. When a field is omitted from the file, the signature fills in the same defaults createChannel uses (DEFAULT_ORDERING = "SHUFFLE", DEFAULT_SORT_FIELD = "title", DEFAULT_SORT_DIR = "asc" — import.ts:56) so an imported channel matches an equivalent local one that was created with defaults.
The filter is canonicalized via a deterministic JSON stringify (canonicalJson, import.ts:66) that recursively sorts object keys and drops undefined, so two filter trees that are structurally equal but written key-in-different-order hash identically. The per-channel filters are themselves sorted before hashing, so definition order doesn't change the signature.
Matching:
- Packages dedupe by
key— reused, never duplicated (action: "reuse"). - Channels dedupe by signature.
loadExistingSignatures(import.ts:151) hashes every existing channel; an imported channel whose signature is in that set getsaction: "skip-duplicate"and is reported "identical channel already exists." - A partial match is not a duplicate. Same name but an edited filter → different signature → imports as a new channel (with the number probe).
previewImportcomputes both sides' signatures the same way, so staging's "already imported" flag agrees with what the import brains actually do.
Dry-run
A dryRun toggle on the staging screen threads through transfer.import({ dryRun }) → the workflow args → the import brains. When on, the workflow runs end-to-end for real but skips every persisting write:
planImportruns fully — dedupe, number assignment (reserving in-memory), library remap.- Each channel's PREDICATE filter is resolved against the target's Plex (
resolveDryRunPool,import.ts:503) to compute a true pool size — INCLUDE defs add, EXCLUDE defs remove, deduped byratingKey, the same combining the real build does. This surfaces the real match count and catches filters that match nothing. - Trace rows are still written (
recordImportTrace) so the run page shows live progress. - But no package, channel, or schedule is created —
executePackagePlanandexecuteChannelPlanshort-circuit before every write whendryRunis set (import.ts:465,import.ts:558).
Its purpose: validate the workflow engine + observability on a deployed build (testing means a tagged release onto TrueNAS) without importing anything, and give a deeper preview than the fast structural staging screen. The run page banners "DRY RUN — nothing was imported."
How it executes
The actual import runs as a durable WDK workflow, not inline in the request. transfer.import dispatches it through a runner registered at server startup (packages/api/src/services/transfer/import-runner.ts — an indirection so packages/api can trigger a workflow that lives in apps/server without importing it), and returns a runId the staging page navigates to.
The workflow is importLineupWorkflow in apps/server/workflows/import.ts: planStep → createPackagesStep → buildChannelStep ×N (fan-out, concurrency 4) → reportStep. It's the same durable machinery as the AI lineup workflow minus the AI — the plan is deterministic and each build step just creates the channel + its PREDICATE definitions and lays a windowed initial schedule (generateChannelSchedule) so the channel is watchable immediately; the hourly refresh grows it afterward. Build steps are retry-safe: because planImport assigns numbers from the free set, a unique-constraint hit (P2002) on an assigned number means a prior attempt already created that channel, so it's treated as already-created rather than failing the run (import.ts:629).
Steps write ImportTrace rows (import-trace.ts / import-runs.ts) that power the multi-tier run page at /settings/workflows/import/$runId (packages tier over channels tier, live-polling, dry-run banner).
Note: the durable-execution mechanics — "use workflow" vs "use step", checkpointing, retries, why editing a workflow requires re-running bunx workflow build and a server restart — are shared with the AI lineup builder and documented in docs/workflows.md rather than repeated here. (The separate in-process job scheduler is covered in docs/jobs.md.)
