Files
Copilot e72bde9c02 Add Jellyfin.DbMigrator SQLite-to-PostgreSQL migration tool (#14)
* Initial plan

* Add Jellyfin.DbMigrator SQLite-to-PostgreSQL migration tool

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:56:07 -04:00

245 lines
7.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Amazon;
using Amazon.S3;
using Amazon.S3.Transfer;
using Jellyfin.DbMigrator;
using Microsoft.Data.Sqlite;
using Npgsql;
// ---------------------------------------------------------------------------
// Ordered table list (respects FK constraints).
// ---------------------------------------------------------------------------
string[] tableOrder =
[
// Group 1 no FK dependencies
"Users",
"ApiKeys",
"Devices",
"DeviceOptions",
// Group 2 BaseItems (self-referencing FK only)
"BaseItems",
// Group 3 children of BaseItems + ItemValues
"AncestorIds",
"BaseItemImageInfos",
"BaseItemMetadataFields",
"BaseItemTrailerTypes",
"BaseItemProviders",
"Chapters",
"ItemValues",
"ItemValuesMap",
"MediaStreamInfos",
"AttachmentStreamInfos",
"KeyframeData",
// Group 4 People
"Peoples",
"PeopleBaseItemMap",
// Group 5 User-related data
"UserData",
"MediaSegments",
"TrickplayInfos",
// Group 6 Misc / user preferences
"ActivityLogs",
"AccessSchedules",
"Permissions",
"Preferences",
"DisplayPreferences",
"ItemDisplayPreferences",
"CustomItemDisplayPreferences",
"ImageInfos",
];
// ---------------------------------------------------------------------------
// Parse command-line arguments.
// ---------------------------------------------------------------------------
string? sqlitePath = null;
string? postgresConnectionString = null;
bool isDryRun = false;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--sqlite" when i + 1 < args.Length:
sqlitePath = args[++i];
break;
case "--postgres" when i + 1 < args.Length:
postgresConnectionString = args[++i];
break;
case "--dry-run":
isDryRun = true;
break;
}
}
if (string.IsNullOrWhiteSpace(sqlitePath) || string.IsNullOrWhiteSpace(postgresConnectionString))
{
await Console.Error.WriteLineAsync(
"Usage: Jellyfin.DbMigrator --sqlite <path> --postgres <connection-string> [--dry-run]")
.ConfigureAwait(false);
return 2;
}
if (!File.Exists(sqlitePath))
{
await Console.Error.WriteLineAsync($"SQLite database not found: {sqlitePath}")
.ConfigureAwait(false);
return 2;
}
if (isDryRun)
{
await Console.Out.WriteLineAsync("[dry-run] No data will be written to PostgreSQL.")
.ConfigureAwait(false);
}
// ---------------------------------------------------------------------------
// Pre-migration S3 backup.
// ---------------------------------------------------------------------------
string? s3Bucket = Environment.GetEnvironmentVariable("S3_BACKUP_BUCKET");
string? awsRegion = Environment.GetEnvironmentVariable("AWS_DEFAULT_REGION");
if (!string.IsNullOrWhiteSpace(s3Bucket) && !string.IsNullOrWhiteSpace(awsRegion))
{
await Console.Out.WriteLineAsync($"Uploading {sqlitePath} to s3://{s3Bucket}/ in region {awsRegion}…")
.ConfigureAwait(false);
try
{
await UploadToS3Async(sqlitePath, s3Bucket, awsRegion, isDryRun).ConfigureAwait(false);
await Console.Out.WriteLineAsync("S3 backup complete.").ConfigureAwait(false);
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"S3 backup failed (continuing): {ex.Message}")
.ConfigureAwait(false);
}
}
else
{
await Console.Out.WriteLineAsync(
"S3_BACKUP_BUCKET or AWS_DEFAULT_REGION not set skipping pre-migration backup.")
.ConfigureAwait(false);
}
// ---------------------------------------------------------------------------
// Open connections.
// ---------------------------------------------------------------------------
var sqliteConnectionString = new SqliteConnectionStringBuilder
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadOnly,
}.ToString();
await using var sqliteConnection = new SqliteConnection(sqliteConnectionString);
await sqliteConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false);
await using var pgConnection = new NpgsqlConnection(postgresConnectionString);
await pgConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false);
// ---------------------------------------------------------------------------
// Migrate tables.
// ---------------------------------------------------------------------------
var reports = new List<TableReport>();
bool anyFailure = false;
foreach (var tableName in tableOrder)
{
await Console.Out.WriteLineAsync($"Migrating table: {tableName}").ConfigureAwait(false);
long sqliteCount = 0L;
long pgCount = 0L;
string? error = null;
try
{
// Read from SQLite.
sqliteCount = await SqliteTableReader.CountRowsAsync(
sqliteConnection, tableName).ConfigureAwait(false);
if (sqliteCount < 0)
{
await Console.Out.WriteLineAsync($" Table \"{tableName}\" not found in SQLite skipping.")
.ConfigureAwait(false);
reports.Add(new TableReport(tableName, 0L, 0L, null));
continue;
}
await Console.Out.WriteLineAsync($" SQLite rows: {sqliteCount}").ConfigureAwait(false);
var rows = await SqliteTableReader.ReadAllRowsAsync(
sqliteConnection, tableName).ConfigureAwait(false);
// Write to PostgreSQL.
long inserted = await PostgresBulkWriter.WriteTableAsync(
pgConnection, tableName, rows, isDryRun).ConfigureAwait(false);
await Console.Out.WriteLineAsync($" Inserted: {inserted}").ConfigureAwait(false);
// Verify row count in PostgreSQL.
pgCount = isDryRun
? 0L
: await PostgresBulkWriter.CountRowsAsync(pgConnection, tableName).ConfigureAwait(false);
}
catch (Exception ex)
{
error = ex.Message;
anyFailure = true;
await Console.Error.WriteLineAsync($" ERROR migrating \"{tableName}\": {ex.Message}")
.ConfigureAwait(false);
}
reports.Add(new TableReport(tableName, sqliteCount, pgCount, error));
}
// ---------------------------------------------------------------------------
// Advance PostgreSQL sequences.
// ---------------------------------------------------------------------------
await Console.Out.WriteLineAsync("Advancing PostgreSQL sequences…").ConfigureAwait(false);
await PostgresBulkWriter.AdvanceSequencesAsync(
pgConnection, tableOrder, isDryRun).ConfigureAwait(false);
// ---------------------------------------------------------------------------
// Print report.
// ---------------------------------------------------------------------------
MigrationReport.Print(reports);
return anyFailure ? 1 : 0;
// ---------------------------------------------------------------------------
// Local functions.
// ---------------------------------------------------------------------------
// Uploads a file to the configured S3 bucket before migration starts.
static async Task UploadToS3Async(
string filePath,
string bucket,
string region,
bool isDryRun)
{
if (isDryRun)
{
await Console.Out.WriteLineAsync(
$" [dry-run] Would upload \"{filePath}\" to s3://{bucket}/{Path.GetFileName(filePath)}")
.ConfigureAwait(false);
return;
}
var regionEndpoint = RegionEndpoint.GetBySystemName(region);
using var s3Client = new AmazonS3Client(regionEndpoint);
using var transferUtility = new TransferUtility(s3Client);
string key = $"jellyfin-db-backups/{Path.GetFileName(filePath)}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.bak";
await transferUtility.UploadAsync(filePath, bucket, key).ConfigureAwait(false);
await Console.Out.WriteLineAsync($" Uploaded to s3://{bucket}/{key}").ConfigureAwait(false);
}