Skip to main content

Manual Migration

This guide walks you through manually exporting data from PeerTube and importing it into Vidra. Use this approach when you can't connect Vidra directly to the PeerTube database, need to transform data before import, or want full control over every step.

Overview

Step 1: Export PeerTube Database

On the PeerTube server:

# Full dump in custom format (compressed, supports parallel restore)
pg_dump -Fc -d peertube_prod -f peertube-export.dump

# Or plain SQL for inspection
pg_dump -d peertube_prod -f peertube-export.sql

Transfer the dump to your Vidra server:

scp peertube-export.dump vidra-server:/tmp/

Step 2: Create a Staging Database

Restore the PeerTube dump into a temporary staging database — never import directly into Vidra's production database.

# Create staging database
createdb peertube_staging

# Restore PeerTube dump
pg_restore -d peertube_staging /tmp/peertube-export.dump

# Verify it's populated
psql peertube_staging -c "SELECT COUNT(*) FROM \"user\";"
Enable dblink

The import scripts use PostgreSQL's dblink extension to query the staging database from Vidra's database. Enable it on Vidra's database:

CREATE EXTENSION IF NOT EXISTS dblink;

Step 3: Transform and Import Data

The following SQL scripts extract data from the PeerTube staging database and insert it into Vidra's schema. Run them in order — later scripts depend on earlier ones.

PeerTube Schema Reference

These scripts are validated against PeerTube v6.x database schemas. The key PeerTube tables involved are:

  • "user" — User accounts (integer IDs, numeric roles: 0=admin, 1=moderator, 2=user)
  • account — User profiles (linked to "user" via "userId")
  • "videoChannel" — Channels (linked to account via "accountId")
  • video — Video metadata (linked to "videoChannel" via "channelId")
  • "videoComment" — Comments with threading via "inReplyToCommentId"
  • "videoPlaylist" — Playlists (linked to account via "ownerAccountId")
  • "videoCaption" — Subtitle/caption files

3a. Import Users

PeerTube splits user data across "user" and account tables. Vidra merges these into a single users table.

-- Connect to Vidra's database
-- Run against: vidra_prod (or your Vidra DB name)

INSERT INTO users (id, username, email, role, is_active, password_hash, created_at, updated_at)
SELECT
gen_random_uuid(),
sub.name,
sub.email,
CASE sub.role
WHEN 0 THEN 'admin'
WHEN 1 THEN 'moderator'
ELSE 'user'
END,
NOT sub.blocked, -- Vidra uses is_active (inverse of PeerTube's blocked)
-- Placeholder hash — users must reset passwords after migration
'$argon2id$v=19$m=65536,t=3,p=4$MIGRATION_PLACEHOLDER$MIGRATION_PLACEHOLDER',
sub."createdAt",
sub."updatedAt"
FROM dblink('dbname=peertube_staging', '
SELECT u.id, u.email, u.role, u.blocked, u."createdAt", u."updatedAt",
a.name
FROM "user" u
JOIN account a ON a."userId" = u.id
') AS sub(
user_id int, email text, role int, blocked boolean,
"createdAt" timestamptz, "updatedAt" timestamptz,
name text
)
-- Deduplicate by username (e.g., "admin" may already exist)
ON CONFLICT (username) DO NOTHING;
Password Reset Required

PeerTube uses bcrypt for password hashing. Vidra uses Argon2id. Passwords cannot be migrated directly — all users will need to reset their passwords after migration. The placeholder hash above prevents login until reset. Consider sending a bulk password-reset email after cutover.

3b. Import Channels

PeerTube's "videoChannel" table maps to Vidra's channels table. The channel name in PeerTube becomes the handle in Vidra.

INSERT INTO channels (id, account_id, handle, display_name, description, support, created_at, updated_at)
SELECT
gen_random_uuid(),
u.id, -- Vidra user UUID (matched by username)
sub.name, -- PeerTube channel name → Vidra handle
sub.name, -- Also used as display_name (update manually after import)
sub.description,
sub.support,
sub."createdAt",
sub."updatedAt"
FROM dblink('dbname=peertube_staging', '
SELECT vc.name, vc.description, vc.support,
vc."createdAt", vc."updatedAt",
a.name AS owner_name
FROM "videoChannel" vc
JOIN account a ON a.id = vc."accountId"
') AS sub(
name text, description text, support text,
"createdAt" timestamptz, "updatedAt" timestamptz,
owner_name text
)
JOIN users u ON u.username = sub.owner_name
ON CONFLICT (handle) DO NOTHING;
Channel Display Names

PeerTube derives channel display names from the associated actor record, but the database "videoChannel" table stores only name (the handle). After import, users can update their channel display names through the Vidra UI or API.

3c. Import Videos

INSERT INTO videos (
id, title, description, privacy, duration, category,
language, channel_id, user_id, thumbnail_id, status,
views, likes_count, dislikes_count,
upload_date, created_at, updated_at
)
SELECT
gen_random_uuid(),
sub.name, -- PeerTube video name → Vidra title
sub.description,
CASE sub.privacy
WHEN 1 THEN 'public'
WHEN 2 THEN 'unlisted'
WHEN 3 THEN 'private'
ELSE 'private' -- PeerTube's "internal" (4) maps to private
END,
sub.duration,
sub.category::text, -- PeerTube uses integer categories, Vidra uses varchar
sub.language,
c.id, -- Vidra channel UUID (matched by handle)
u.id, -- Vidra user UUID (channel owner)
gen_random_uuid(), -- Placeholder thumbnail_id (required NOT NULL)
'completed', -- Already-processed videos
sub.views,
sub.likes,
sub.dislikes,
sub."publishedAt", -- PeerTube publishedAt → Vidra upload_date
sub."createdAt",
sub."updatedAt"
FROM dblink('dbname=peertube_staging', '
SELECT v.name, v.description, v.privacy, v.duration,
v.category, v.language, v.views, v.likes, v.dislikes,
v."createdAt", v."updatedAt", v."publishedAt",
vc.name AS channel_name
FROM video v
JOIN "videoChannel" vc ON vc.id = v."channelId"
WHERE v.remote = false
') AS sub(
name text, description text, privacy int, duration int,
category int, language text, views int, likes int, dislikes int,
"createdAt" timestamptz, "updatedAt" timestamptz, "publishedAt" timestamptz,
channel_name text
)
JOIN channels c ON c.handle = sub.channel_name
JOIN users u ON u.id = c.account_id;
Remote Videos

The query above filters WHERE v.remote = false to import only local videos. Remote (federated) videos will be rediscovered through ActivityPub after cutover. If you want to import remote video metadata too, remove the filter.

3d. Import Comments

-- First pass: top-level comments (no parent)
INSERT INTO comments (id, video_id, user_id, body, status, created_at, updated_at)
SELECT
gen_random_uuid(),
v.id,
u.id,
sub.text, -- PeerTube text → Vidra body
'active',
sub."createdAt",
sub."updatedAt"
FROM dblink('dbname=peertube_staging', '
SELECT vc.id, vc.text, vc."createdAt", vc."updatedAt",
vc."inReplyToCommentId",
vid.name AS video_title,
a.name AS author_name
FROM "videoComment" vc
JOIN video vid ON vid.id = vc."videoId"
JOIN account a ON a.id = vc."accountId"
WHERE vc."inReplyToCommentId" IS NULL
AND vc."deletedAt" IS NULL
') AS sub(
pt_id int, text text, "createdAt" timestamptz, "updatedAt" timestamptz,
in_reply_to int,
video_title text, author_name text
)
JOIN videos v ON v.title = sub.video_title
JOIN users u ON u.username = sub.author_name;

-- Second pass: replies (with parent_id linking)
-- This requires a PeerTube-ID-to-Vidra-UUID mapping table.
-- See the troubleshooting guide for handling deep thread trees.
Comment Threading

For full reply threading, create a temporary mapping table during the first pass that records PeerTube integer IDs alongside the new Vidra UUIDs. Then use the mapping in the second pass to set parent_id. See Troubleshooting: Comment threading broken for the complete two-pass approach.

3e. Import Playlists

INSERT INTO playlists (id, user_id, name, description, privacy, created_at, updated_at)
SELECT
gen_random_uuid(),
u.id, -- Vidra user UUID
sub.name, -- PeerTube playlist name → Vidra name
sub.description,
CASE sub.privacy
WHEN 1 THEN 'public'
WHEN 2 THEN 'unlisted'
ELSE 'private'
END,
sub."createdAt",
sub."updatedAt"
FROM dblink('dbname=peertube_staging', '
SELECT vp.name, vp.description, vp.privacy,
vp."createdAt", vp."updatedAt",
a.name AS owner_name
FROM "videoPlaylist" vp
JOIN account a ON a.id = vp."ownerAccountId"
') AS sub(
name text, description text, privacy int,
"createdAt" timestamptz, "updatedAt" timestamptz,
owner_name text
)
JOIN users u ON u.username = sub.owner_name;

3f. Import Playlist Items

INSERT INTO playlist_items (id, playlist_id, video_id, position, added_at)
SELECT
gen_random_uuid(),
p.id, -- Vidra playlist UUID (matched by name + owner)
v.id, -- Vidra video UUID (matched by title)
sub.position,
sub."createdAt"
FROM dblink('dbname=peertube_staging', '
SELECT vpe."position", vpe."createdAt",
vp.name AS playlist_name,
a.name AS owner_name,
vid.name AS video_title
FROM "videoPlaylistElement" vpe
JOIN "videoPlaylist" vp ON vp.id = vpe."videoPlaylistId"
JOIN account a ON a.id = vp."ownerAccountId"
JOIN video vid ON vid.id = vpe."videoId"
WHERE vpe."videoId" IS NOT NULL
') AS sub(
position int, "createdAt" timestamptz,
playlist_name text, owner_name text, video_title text
)
JOIN users u ON u.username = sub.owner_name
JOIN playlists p ON p.name = sub.playlist_name AND p.user_id = u.id
JOIN videos v ON v.title = sub.video_title;

3g. Import Captions

INSERT INTO captions (id, video_id, language_code, label, file_path, file_format, is_auto_generated, created_at, updated_at)
SELECT
gen_random_uuid(),
v.id, -- Vidra video UUID
sub.language, -- PeerTube language → Vidra language_code
INITCAP(sub.language), -- Derive label from language code (e.g., "en" → "En")
sub.filename, -- PeerTube filename → Vidra file_path
CASE
WHEN sub.filename LIKE '%.srt' THEN 'srt'
ELSE 'vtt' -- Default to VTT (PeerTube's primary format)
END,
sub."automaticallyGenerated",
sub."createdAt",
sub."updatedAt"
FROM dblink('dbname=peertube_staging', '
SELECT vc.language, vc.filename, vc."automaticallyGenerated",
vc."createdAt", vc."updatedAt",
vid.name AS video_title
FROM "videoCaption" vc
JOIN video vid ON vid.id = vc."videoId"
') AS sub(
language text, filename text, "automaticallyGenerated" boolean,
"createdAt" timestamptz, "updatedAt" timestamptz,
video_title text
)
JOIN videos v ON v.title = sub.video_title;
Better Caption Labels

The INITCAP(language) derivation produces basic labels like "En" or "Fr". For human-readable labels ("English", "French"), create a lookup table or update labels after import:

UPDATE captions SET label = 'English' WHERE language_code = 'en';
UPDATE captions SET label = 'French' WHERE language_code = 'fr';
UPDATE captions SET label = 'Spanish' WHERE language_code = 'es';
-- Add more as needed

Step 4: Verify Row Counts

After import, compare counts between the staging database and Vidra:

# PeerTube staging counts
psql peertube_staging -c 'SELECT COUNT(*) FROM "user";'
psql peertube_staging -c 'SELECT COUNT(*) FROM "videoChannel";'
psql peertube_staging -c 'SELECT COUNT(*) FROM video WHERE remote = false;'
psql peertube_staging -c 'SELECT COUNT(*) FROM "videoComment" WHERE "deletedAt" IS NULL;'
psql peertube_staging -c 'SELECT COUNT(*) FROM "videoPlaylist";'
psql peertube_staging -c 'SELECT COUNT(*) FROM "videoCaption";'

# Vidra counts
psql vidra_prod -c 'SELECT COUNT(*) FROM users;'
psql vidra_prod -c 'SELECT COUNT(*) FROM channels;'
psql vidra_prod -c 'SELECT COUNT(*) FROM videos;'
psql vidra_prod -c 'SELECT COUNT(*) FROM comments;'
psql vidra_prod -c 'SELECT COUNT(*) FROM playlists;'
psql vidra_prod -c 'SELECT COUNT(*) FROM captions;'

Small discrepancies are expected (e.g., duplicate usernames resolved by ON CONFLICT, orphaned comments from deleted users). Large discrepancies warrant investigation.

Step 5: Clean Up

# Drop staging database when satisfied
dropdb peertube_staging

# Remove dump file
rm /tmp/peertube-export.dump

Next Steps

  1. Migrate storage — Copy video files and thumbnails
  2. Cutover and validate — Switch DNS and verify