Skip to main content

Command-line importer

peertube-import is the same importer as the admin API path, driven from a terminal. Reach for it when the API cannot do what you need:

  • the source schema version is undetectable and needs --force;
  • the api cannot reach the source database, but your laptop can, over a tunnel;
  • you want the source connection on the command line rather than in server config.

Build it — it ships nowhere

Dockerfile builds only ./cmd/api, and the release assets carry only the vidra CLI. There is no supported way to obtain the importer as a binary.

git clone https://github.com/yegamble/vidra-core.git
cd vidra-core
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o peertube-import ./cmd/peertube-import
scp peertube-import root@<host>:/opt/vidra/

The target is read from the ordinary server configuration — config.Load(), so DATABASE_URL, STORAGE_* and FEDERATION_KEY_KEK — which means the importer runs wherever the Vidra config lives, usually the new host. The source comes from the flags below.

Flags

Source database

FlagDefaultWhat
--source-dsnRequired. Read-only DSN of the source PeerTube PostgreSQL.
--source-dsn has no secret indirection

Every other secret-bearing entry point in this project accepts @path, - for stdin, or an environment variable. This one does not, so a production database password lands in argv where any local user can read it from ps. Treat that as a gap to close, and in the meantime run the import on a host where that exposure is acceptable. The admin API path has no such problem — its source connection lives in server config.

A missing host silently becomes a local unix socket

PostgreSQL drivers fall back to a local socket when the connection string carries no usable host, so a typo'd or missing host becomes a path on this machine. Write postgres://user:password@host:port/database naming the source; if you are tunnelling, name the local end of the tunnel (127.0.0.1:<local port>).

Source media

FlagDefaultWhat
--source-storagelocallocal or s3.
--source-local-rootThe source media directory, read-only. For --source-storage=local.
--source-s3-endpointHost[:port], no scheme.
--source-s3-bucket
--source-s3-access-keyOr PEERTUBE_SOURCE_S3_ACCESS_KEY.
--source-s3-secret-keyOr PEERTUBE_SOURCE_S3_SECRET_KEY.
--source-s3-region
--source-s3-use-ssltrue
--source-s3-force-path-stylefalseSet for MinIO.

Behaviour

FlagDefaultWhat
--dry-runoffReport the plan and conflicts, and write nothing.
--media-modecopycopy, reference or none. See Media migration.
--no-mediaDeprecated alias for --media-mode=none.
--conflict-policyskipskip, rename, merge or fail.
--source-authoritativeoffLet a re-run update rows the import already owns.
--resumeContinue a prior import. The default behaviour already skips already-imported rows.
--forceoffOverride the supported-version refusal. Human operators only.

Run it

Always dry-run first. On a source whose media is already in object storage, no media transits the source host at all — the only thing needing reach into it is the database, and an SSH tunnel is enough.

# Tunnel to the source database (PeerTube binds Postgres to 127.0.0.1).
ssh -fN -L 5433:localhost:5432 user@peertube-server

# 1. Dry run — writes nothing, and sends no HTTP request to the source.
./peertube-import \
--source-dsn 'postgres://readonly:PASSWORD@127.0.0.1:5433/peertube_prod?sslmode=require' \
--source-storage s3 \
--source-s3-endpoint s3.us-east-1.amazonaws.com \
--source-s3-bucket peertube-videos \
--source-s3-region us-east-1 \
--dry-run

# 2. The real run.
./peertube-import \
--source-dsn 'postgres://readonly:PASSWORD@127.0.0.1:5433/peertube_prod?sslmode=require' \
--source-storage s3 \
--source-s3-endpoint s3.us-east-1.amazonaws.com \
--source-s3-bucket peertube-videos \
--source-s3-region us-east-1 \
--media-mode reference

Compare the dry run's counts against your inventory before running it for real.

The version gate, and --force

ClassifyVersion accepts a source migrationVersion in [700, 1000] and refuses anything outside it:

source schema version 1040 is newer than the verified range [700, 1000]
— pass --force only after verifying compatibility

The flag's own help says "HUMAN operators only; agents MUST NOT set this", and that is the right boundary: the refusal is a statement about what has been verified, not about what happens to work.

Given the importer only reads, "verifying compatibility" can reasonably mean two checks:

  1. Every column it references still exists. Extract the quoted identifiers from the importer's SQL and diff them against information_schema.columns on the source. A removed or renamed column is the failure that would break the run loudly. On a 1040 source this came back clean.
  2. What the newer schema added that the importer does not read. This is the quieter risk and the one worth the time: the import does not fail, it silently carries less than you assume.

Neither proves semantic equivalence — a column that still exists but now means something else passes both.

Passwords come across

The importer carries the source's bcrypt hash verbatim, and Vidra verifies against it. Your users sign in with the passwords they already have; there is no bulk reset to send. Neither the hash nor any plaintext appears in the importer's logs — there is a test that asserts it.

The image families are the odd ones out

Avatars, banners, video posters and storyboards are read from a mounted --source-local-root when there is one, and otherwise fetched over HTTP from the source instance — never from its object store. No PeerTube configuration puts them there: object_storage covers streaming playlists, web videos, user exports, originals and captions, full stop. So they live on the source host's local disk whatever the S3 settings say, and --source-storage=s3 cannot see them.

The importer derives the source's public origin from its own actors' canonical URLs — there is no extra flag — and fetches /lazy-static/{avatars,thumbnails,storyboards}/<filename>, four connections at a time, with the bytes content-sniffed before anything is stored. That last part is not belt-and-braces: /static/avatars/<name> answers 200 with the web app's HTML rather than a 404, and a fetch trusting the status code would give every account a 62 KB HTML "avatar".

All four are carried under --media-mode=reference too, because there is no object key to reference. --media-mode=none skips them. An asset the instance already has is never written over.

Re-running

Every pass is keyed by its own ledger rows and runs after its parents, which is what lets a re-run backfill onto entities an earlier release already imported. An instance whose catalogue is already in Vidra gets newly carried data by re-running the importer; nothing has to be re-imported from scratch.

If your migrated instance shows a broken image on every card, re-run the importer. A release before the poster pass recorded each poster as an object key in the source's bucket, which PeerTube never writes — so has_thumbnail said true and every thumbnail 404'd. The poster pass repairs those rows in place and backfills storyboards onto the same catalogue.

What it does not carry

Deliberately: moderation state (video blacklist, account and server blocklists, abuse reports), user notification settings, and watch history.

Not deliberately, just unread: videoSource — original-file provenance records.

The full table is in the overview.

Next