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\";"
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.
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 toaccountvia"accountId")video— Video metadata (linked to"videoChannel"via"channelId")"videoComment"— Comments with threading via"inReplyToCommentId""videoPlaylist"— Playlists (linked toaccountvia"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;
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;
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;
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.
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;
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
- Migrate storage — Copy video files and thumbnails
- Cutover and validate — Switch DNS and verify