feat: migrate runtime to postgresql

This commit is contained in:
西街长安 2026-04-29 18:47:12 +08:00
parent 49d893f29b
commit 8132466c5d
17 changed files with 2347 additions and 306 deletions

View File

@ -1,4 +1,20 @@
services:
postgres:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-live_recorder}
POSTGRES_USER: ${POSTGRES_USER:-live_recorder}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-live_recorder} -d ${POSTGRES_DB:-live_recorder}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
api:
build:
context: .
@ -8,10 +24,13 @@ services:
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim}
DOTNET_RUNTIME_IMAGE: ${DOTNET_RUNTIME_IMAGE:-mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: Data Source=/app/data/live-recorder.db
ConnectionStrings__DefaultConnection: Host=postgres;Port=5432;Database=${POSTGRES_DB:-live_recorder};Username=${POSTGRES_USER:-live_recorder};Password=${POSTGRES_PASSWORD:-change_me}
volumes:
- ./data:/app/data
- ./records:/app/records
@ -32,3 +51,6 @@ services:
- api
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
postgres-data:

View File

@ -0,0 +1,89 @@
# PostgreSQL 切换与 SQLite 历史数据迁移
这份说明对应当前主线版本:应用正式运行数据库已经切换为 PostgreSQLSQLite 仅用于一次性历史数据导入。
## 1. 迁移前准备
1. 备份旧的 SQLite 文件和 `records/` 目录。
2. 停止当前 API 写入流量,避免迁移过程中旧库继续变化。
3. 准备好新的 PostgreSQL 容器:
```bash
docker compose up -d postgres
```
4. 确认 PostgreSQL 已就绪:
```bash
docker compose ps
```
## 2. 运行一次性迁移
迁移命令会:
- 对 PostgreSQL 执行 EF Core migrations
- 检查目标库必须为空
- 只读打开旧 SQLite
- 按既定顺序导入所有业务数据
Docker Compose 部署建议直接这样跑:
```bash
docker compose run --rm api --migrate-sqlite /app/data/live-recorder.db
```
如果你是在宿主机本地直接运行 .NET则可以使用
```bash
dotnet run --project src/LiveRecorder.WebApi -- --migrate-sqlite /app/data/live-recorder.db
```
Windows 本地示例:
```powershell
dotnet run --project src\LiveRecorder.WebApi -- --migrate-sqlite "C:\path\to\live-recorder.db"
```
迁移完成后,控制台会输出每张表的 `source -> target` 行数校验结果。
## 3. 正式切流
1. 启动完整服务:
```bash
docker compose up -d
```
2. 验证这些接口和流程:
- 登录
- `/api/live-rooms`
- `/api/record-sessions`
- `/api/logs`
- 自动开录
- 系统日志持续写入
## 4. Docker 默认环境变量
当前 `docker-compose.yml` 默认使用:
- `POSTGRES_DB=live_recorder`
- `POSTGRES_USER=live_recorder`
- `POSTGRES_PASSWORD=change_me`
正式部署前请至少覆盖 `POSTGRES_PASSWORD`
## 5. 回滚方案
如果切换后需要回滚:
1. 停止新版本 API。
2. 保留 PostgreSQL 数据卷,不做破坏性清理。
3. 用迁移前备份的 SQLite 文件恢复旧版本应用。
4. 检查 `records/` 目录没有被误覆盖后,再重新开放流量。
## 6. 注意事项
- 迁移命令默认拒绝导入到非空 PostgreSQL 数据库。
- SQLite 不再作为正式运行主库,只保留历史迁移用途。
- `/app/data` 目录在 Docker 方案里仍会挂载,主要用于迁移阶段存放旧 SQLite 备份。

View File

@ -46,8 +46,8 @@ public sealed class SystemLogService : ISystemLogService
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
// Logging must be best-effort. If SQLite is locked/full, throwing from here
// causes the scheduler to turn a log persistence failure into an email storm.
// Logging must be best-effort. Throwing from here would turn a log
// persistence hiccup into user-visible scheduling failures and noisy alerts.
_logger.LogWarning(
ex,
"System log write failed. Category={Category}; Message={Message}; LiveRoomId={LiveRoomId}; RecordSessionId={RecordSessionId}; RecordTaskId={RecordTaskId}",

View File

@ -10,8 +10,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
</ItemGroup>
<ItemGroup>

View File

@ -1,7 +1,5 @@
using LiveRecorder.Application.Common;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Persistence;
@ -15,13 +13,17 @@ public sealed class DatabaseInitializer
_dbContext = dbContext;
}
public async Task InitializeAsync(CancellationToken cancellationToken = default)
public Task InitializeAsync(CancellationToken cancellationToken = default) =>
InitializeAsync(seedDefaults: true, cancellationToken);
public async Task InitializeAsync(bool seedDefaults, CancellationToken cancellationToken = default)
{
await _dbContext.Database.EnsureCreatedAsync(cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;", cancellationToken);
await EnsureSchemaAsync(cancellationToken);
await BackfillRecordSessionsAsync(cancellationToken);
await _dbContext.Database.MigrateAsync(cancellationToken);
if (!seedDefaults)
{
return;
}
if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken))
{
@ -79,9 +81,18 @@ public sealed class DatabaseInitializer
["platform_proxy.huya.enabled"] = "False",
["platform_proxy.huya.url"] = string.Empty,
["event_scripts.enabled"] = "False",
["event_scripts.live_started.enabled"] = "False",
["event_scripts.live_started.path"] = string.Empty,
["event_scripts.live_started.content"] = string.Empty,
["event_scripts.live_started.mode"] = "Path",
["event_scripts.live_ended.enabled"] = "False",
["event_scripts.live_ended.path"] = string.Empty,
["event_scripts.live_ended.content"] = string.Empty,
["event_scripts.live_ended.mode"] = "Path",
["event_scripts.segment_completed.enabled"] = "False",
["event_scripts.segment_completed.path"] = string.Empty,
["event_scripts.segment_completed.content"] = string.Empty,
["event_scripts.segment_completed.mode"] = "Path",
["event_scripts.timeout_seconds"] = "60",
["retention.cleanup.enabled"] = "False",
["retention.cleanup.days"] = "30",
@ -150,238 +161,4 @@ public sealed class DatabaseInitializer
await _dbContext.SaveChangesAsync(cancellationToken);
}
private async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
try
{
await _dbContext.Database.ExecuteSqlRawAsync(
"ALTER TABLE LiveRooms ADD COLUMN IsEnabled INTEGER NOT NULL DEFAULT 1;",
cancellationToken);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1 &&
ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
{
}
try
{
await _dbContext.Database.ExecuteSqlRawAsync(
"ALTER TABLE LiveRooms ADD COLUMN HasSentLiveNotificationForCurrentSession INTEGER NOT NULL DEFAULT 0;",
cancellationToken);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1 &&
ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
{
}
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN AvatarUrl TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN AnchorId TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN PreferredQualityOverride TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN OutputFormatOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN SaveModeOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN RecordingTemplateOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN SegmentDurationMinutesOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN EnableAutoReconnectOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN ReconnectDelayMaxSecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN ReadWriteTimeoutMillisecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN EnableDanmakuRecordingOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuIncludeNonChatEventsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuMinPollIntervalMillisecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuRetryDelayMaxSecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionCode TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionSummary TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionDetail TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionAt TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Remark TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPinned INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Alias TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPriority INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN PollingIntervalSecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastStartRecordingTriggeredAt TEXT NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS RecordSessions (
Id TEXT NOT NULL CONSTRAINT PK_RecordSessions PRIMARY KEY,
LiveRoomId TEXT NOT NULL,
Status INTEGER NOT NULL,
PreferredQuality TEXT NOT NULL,
OutputFormat INTEGER NOT NULL,
SaveMode INTEGER NOT NULL,
StreamUrl TEXT NULL,
OutputPathPattern TEXT NULL,
ActiveSegmentIndex INTEGER NOT NULL,
SegmentCount INTEGER NOT NULL,
RecorderProcessId INTEGER NULL,
ErrorMessage TEXT NULL,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
StartedAt TEXT NULL,
EndedAt TEXT NULL
);
""",
cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN SegmentIndex INTEGER NOT NULL DEFAULT 1;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuFilePath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuMessageCount INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadStatus INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadProvider TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteVideoPath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteDanmakuPath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadedAt TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadErrorMessage TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DeletedLocalFilesAfterUpload INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE SystemLogEntries ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"CREATE INDEX IF NOT EXISTS IX_RecordTasks_RecordSessionId_SegmentIndex ON RecordTasks (RecordSessionId, SegmentIndex);",
cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"CREATE INDEX IF NOT EXISTS IX_RecordSessions_LiveRoomId_CreatedAt ON RecordSessions (LiveRoomId, CreatedAt);",
cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"CREATE INDEX IF NOT EXISTS IX_SystemLogEntries_RecordSessionId ON SystemLogEntries (RecordSessionId);",
cancellationToken);
}
private async Task ExecuteAddColumnAsync(string sql, CancellationToken cancellationToken)
{
try
{
await _dbContext.Database.ExecuteSqlRawAsync(sql, cancellationToken);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1 &&
ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
{
}
}
private async Task BackfillRecordSessionsAsync(CancellationToken cancellationToken)
{
await using var connection = (SqliteConnection)_dbContext.Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
{
await connection.OpenAsync(cancellationToken);
}
var selectCommand = connection.CreateCommand();
selectCommand.CommandText =
"""
SELECT Id, LiveRoomId, Status, PreferredQuality, OutputFormat, StreamUrl, OutputFilePath, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt
FROM RecordTasks
WHERE RecordSessionId IS NULL OR RecordSessionId = '';
""";
var orphanTasks = new List<LegacyTaskRow>();
await using (var reader = await selectCommand.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
orphanTasks.Add(new LegacyTaskRow(
reader.GetString(0),
reader.GetString(1),
reader.GetInt32(2),
reader.IsDBNull(3) ? "origin" : reader.GetString(3),
reader.GetInt32(4),
reader.IsDBNull(5) ? null : reader.GetString(5),
reader.IsDBNull(6) ? null : reader.GetString(6),
reader.IsDBNull(7) ? null : reader.GetString(7),
reader.GetString(8),
reader.GetString(9),
reader.IsDBNull(10) ? null : reader.GetString(10),
reader.IsDBNull(11) ? null : reader.GetString(11)));
}
}
foreach (var orphanTask in orphanTasks)
{
var sessionId = Guid.NewGuid().ToString();
var saveMode = !string.IsNullOrWhiteSpace(orphanTask.OutputFilePath) && orphanTask.OutputFilePath.Contains('%')
? (int)RecordSaveMode.Segmented
: (int)RecordSaveMode.SingleFile;
var segmentCount = 1;
var activeSegmentIndex = orphanTask.Status is (int)RecordTaskStatus.Starting or (int)RecordTaskStatus.Running or (int)RecordTaskStatus.Stopping
? 1
: 0;
var insertSessionCommand = connection.CreateCommand();
insertSessionCommand.CommandText =
"""
INSERT INTO RecordSessions (
Id, LiveRoomId, Status, PreferredQuality, OutputFormat, SaveMode, StreamUrl, OutputPathPattern,
ActiveSegmentIndex, SegmentCount, RecorderProcessId, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt
) VALUES (
$id, $liveRoomId, $status, $preferredQuality, $outputFormat, $saveMode, $streamUrl, $outputPathPattern,
$activeSegmentIndex, $segmentCount, NULL, $errorMessage, $createdAt, $updatedAt, $startedAt, $endedAt
);
""";
insertSessionCommand.Parameters.AddWithValue("$id", sessionId);
insertSessionCommand.Parameters.AddWithValue("$liveRoomId", orphanTask.LiveRoomId);
insertSessionCommand.Parameters.AddWithValue("$status", MapLegacyTaskStatusToSessionStatus(orphanTask.Status));
insertSessionCommand.Parameters.AddWithValue("$preferredQuality", orphanTask.PreferredQuality);
insertSessionCommand.Parameters.AddWithValue("$outputFormat", orphanTask.OutputFormat);
insertSessionCommand.Parameters.AddWithValue("$saveMode", saveMode);
insertSessionCommand.Parameters.AddWithValue("$streamUrl", (object?)orphanTask.StreamUrl ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$outputPathPattern", (object?)orphanTask.OutputFilePath ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$activeSegmentIndex", activeSegmentIndex);
insertSessionCommand.Parameters.AddWithValue("$segmentCount", segmentCount);
insertSessionCommand.Parameters.AddWithValue("$errorMessage", (object?)orphanTask.ErrorMessage ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$createdAt", orphanTask.CreatedAt);
insertSessionCommand.Parameters.AddWithValue("$updatedAt", orphanTask.UpdatedAt);
insertSessionCommand.Parameters.AddWithValue("$startedAt", (object?)orphanTask.StartedAt ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$endedAt", (object?)orphanTask.EndedAt ?? DBNull.Value);
await insertSessionCommand.ExecuteNonQueryAsync(cancellationToken);
var updateTaskCommand = connection.CreateCommand();
updateTaskCommand.CommandText =
"""
UPDATE RecordTasks
SET RecordSessionId = $recordSessionId, SegmentIndex = COALESCE(SegmentIndex, 1)
WHERE Id = $taskId;
""";
updateTaskCommand.Parameters.AddWithValue("$recordSessionId", sessionId);
updateTaskCommand.Parameters.AddWithValue("$taskId", orphanTask.Id);
await updateTaskCommand.ExecuteNonQueryAsync(cancellationToken);
var updateLogsCommand = connection.CreateCommand();
updateLogsCommand.CommandText =
"""
UPDATE SystemLogEntries
SET RecordSessionId = $recordSessionId
WHERE RecordTaskId = $taskId AND (RecordSessionId IS NULL OR RecordSessionId = '');
""";
updateLogsCommand.Parameters.AddWithValue("$recordSessionId", sessionId);
updateLogsCommand.Parameters.AddWithValue("$taskId", orphanTask.Id);
await updateLogsCommand.ExecuteNonQueryAsync(cancellationToken);
}
}
private static int MapLegacyTaskStatusToSessionStatus(int taskStatus) =>
taskStatus switch
{
(int)RecordTaskStatus.Pending => (int)RecordSessionStatus.Pending,
(int)RecordTaskStatus.Starting => (int)RecordSessionStatus.Starting,
(int)RecordTaskStatus.Running => (int)RecordSessionStatus.Running,
(int)RecordTaskStatus.Stopping => (int)RecordSessionStatus.Stopping,
(int)RecordTaskStatus.Completed => (int)RecordSessionStatus.Completed,
(int)RecordTaskStatus.Failed => (int)RecordSessionStatus.Failed,
(int)RecordTaskStatus.Stopped => (int)RecordSessionStatus.Stopped,
_ => (int)RecordSessionStatus.Stopped
};
private sealed record LegacyTaskRow(
string Id,
string LiveRoomId,
int Status,
string PreferredQuality,
int OutputFormat,
string? StreamUrl,
string? OutputFilePath,
string? ErrorMessage,
string CreatedAt,
string UpdatedAt,
string? StartedAt,
string? EndedAt);
}

View File

@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace LiveRecorder.Infrastructure.Persistence;
public sealed class LiveRecorderDesignTimeDbContextFactory : IDesignTimeDbContextFactory<LiveRecorderDbContext>
{
public LiveRecorderDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<LiveRecorderDbContext>();
optionsBuilder.UseNpgsql(
"Host=localhost;Port=5432;Database=live_recorder;Username=postgres;Password=postgres");
return new LiveRecorderDbContext(optionsBuilder.Options);
}
}

View File

@ -0,0 +1,584 @@
// <auto-generated />
using System;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LiveRecorderDbContext))]
[Migration("20260429103005_InitialPostgreSqlSchema")]
partial class InitialPostgreSqlSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("LiveRecorder.Domain.Entities.AppSetting", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("AppSettings", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Alias")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("AvailabilityStatus")
.HasColumnType("integer");
b.Property<string>("AvatarUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool?>("DanmakuIncludeNonChatEventsOverride")
.HasColumnType("boolean");
b.Property<int?>("DanmakuMinPollIntervalMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("DanmakuRetryDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<bool?>("EnableAutoReconnectOverride")
.HasColumnType("boolean");
b.Property<bool?>("EnableDanmakuRecordingOverride")
.HasColumnType("boolean");
b.Property<bool>("HasSentLiveNotificationForCurrentSession")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsPinned")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsPriority")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastAutoStartDecisionAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastAutoStartDecisionCode")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LastAutoStartDecisionDetail")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("LastAutoStartDecisionSummary")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastCheckedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastStartRecordingTriggeredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int?>("OutputFormatOverride")
.HasColumnType("integer");
b.Property<int>("Platform")
.HasColumnType("integer");
b.Property<int?>("PollingIntervalSecondsOverride")
.HasColumnType("integer");
b.Property<string>("PreferredQualityOverride")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ReadWriteTimeoutMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("ReconnectDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<int?>("RecordingTemplateOverride")
.HasColumnType("integer");
b.Property<string>("Remark")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int?>("SaveModeOverride")
.HasColumnType("integer");
b.Property<int?>("SegmentDurationMinutesOverride")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Platform", "RoomId")
.IsUnique();
b.ToTable("LiveRooms", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DanmakuFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DanmakuMessageCount")
.HasColumnType("integer");
b.Property<bool>("DeletedLocalFilesAfterUpload")
.HasColumnType("boolean");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<long?>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<int>("FinalStatus")
.HasColumnType("integer");
b.Property<string>("LastUploadProvider")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset?>("LastUploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RecordTaskId")
.HasColumnType("uuid");
b.Property<string>("RemoteDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RemoteVideoPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("UploadErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("UploadStatus")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RecordTaskId")
.IsUnique();
b.ToTable("RecordResults", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("ActiveSegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("OutputPathPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SaveMode")
.HasColumnType("integer");
b.Property<int>("SegmentCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.ToTable("RecordSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("OutputFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("RecordSessionId")
.HasColumnType("uuid");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.HasIndex("RecordSessionId", "SegmentIndex");
b.ToTable("RecordTasks", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<Guid?>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("RecordSessionId")
.HasColumnType("uuid");
b.Property<Guid?>("RecordTaskId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("RecordSessionId");
b.ToTable("SystemLogEntries", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("UserAccounts", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserAccountId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
.WithOne("Result")
.HasForeignKey("LiveRecorder.Domain.Entities.RecordResult", "RecordTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecordTask");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany()
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany("RecordTasks")
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiveRecorder.Domain.Entities.RecordSession", "RecordSession")
.WithMany("RecordTasks")
.HasForeignKey("RecordSessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
b.Navigation("RecordSession");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("UserAccount");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Navigation("Result");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,325 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialPostgreSqlSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Value = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LiveRooms",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Platform = table.Column<int>(type: "integer", nullable: false),
SourceUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
RoomId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
NormalizedUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AnchorName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
AnchorId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
AvatarUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
CoverUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Remark = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
IsPinned = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
Alias = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
IsPriority = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
PollingIntervalSecondsOverride = table.Column<int>(type: "integer", nullable: true),
PreferredQualityOverride = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
OutputFormatOverride = table.Column<int>(type: "integer", nullable: true),
SaveModeOverride = table.Column<int>(type: "integer", nullable: true),
RecordingTemplateOverride = table.Column<int>(type: "integer", nullable: true),
SegmentDurationMinutesOverride = table.Column<int>(type: "integer", nullable: true),
EnableAutoReconnectOverride = table.Column<bool>(type: "boolean", nullable: true),
ReconnectDelayMaxSecondsOverride = table.Column<int>(type: "integer", nullable: true),
ReadWriteTimeoutMillisecondsOverride = table.Column<int>(type: "integer", nullable: true),
EnableDanmakuRecordingOverride = table.Column<bool>(type: "boolean", nullable: true),
DanmakuIncludeNonChatEventsOverride = table.Column<bool>(type: "boolean", nullable: true),
DanmakuMinPollIntervalMillisecondsOverride = table.Column<int>(type: "integer", nullable: true),
DanmakuRetryDelayMaxSecondsOverride = table.Column<int>(type: "integer", nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
HasSentLiveNotificationForCurrentSession = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
AvailabilityStatus = table.Column<int>(type: "integer", nullable: false),
LastAutoStartDecisionCode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
LastAutoStartDecisionSummary = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
LastAutoStartDecisionDetail = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
LastAutoStartDecisionAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastCheckedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastStartRecordingTriggeredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LiveRooms", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SystemLogEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Level = table.Column<int>(type: "integer", nullable: false),
Category = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Message = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Detail = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
LiveRoomId = table.Column<Guid>(type: "uuid", nullable: true),
RecordSessionId = table.Column<Guid>(type: "uuid", nullable: true),
RecordTaskId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SystemLogEntries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserAccounts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Username = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
DisplayName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserAccounts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RecordSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
LiveRoomId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
PreferredQuality = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
OutputFormat = table.Column<int>(type: "integer", nullable: false),
SaveMode = table.Column<int>(type: "integer", nullable: false),
StreamUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
OutputPathPattern = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
ActiveSegmentIndex = table.Column<int>(type: "integer", nullable: false),
SegmentCount = table.Column<int>(type: "integer", nullable: false),
RecorderProcessId = table.Column<int>(type: "integer", nullable: true),
ErrorMessage = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
EndedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecordSessions", x => x.Id);
table.ForeignKey(
name: "FK_RecordSessions_LiveRooms_LiveRoomId",
column: x => x.LiveRoomId,
principalTable: "LiveRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserSessions", x => x.Id);
table.ForeignKey(
name: "FK_UserSessions_UserAccounts_UserAccountId",
column: x => x.UserAccountId,
principalTable: "UserAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RecordTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
LiveRoomId = table.Column<Guid>(type: "uuid", nullable: false),
RecordSessionId = table.Column<Guid>(type: "uuid", nullable: false),
SegmentIndex = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
PreferredQuality = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
OutputFormat = table.Column<int>(type: "integer", nullable: false),
StreamUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
OutputFilePath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
RecorderProcessId = table.Column<int>(type: "integer", nullable: true),
ErrorMessage = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
EndedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
DurationSeconds = table.Column<double>(type: "double precision", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecordTasks", x => x.Id);
table.ForeignKey(
name: "FK_RecordTasks_LiveRooms_LiveRoomId",
column: x => x.LiveRoomId,
principalTable: "LiveRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RecordTasks_RecordSessions_RecordSessionId",
column: x => x.RecordSessionId,
principalTable: "RecordSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RecordResults",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RecordTaskId = table.Column<Guid>(type: "uuid", nullable: false),
FilePath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
FileSizeBytes = table.Column<long>(type: "bigint", nullable: true),
DurationSeconds = table.Column<double>(type: "double precision", nullable: true),
DanmakuFilePath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DanmakuMessageCount = table.Column<int>(type: "integer", nullable: false),
FinalStatus = table.Column<int>(type: "integer", nullable: false),
ErrorMessage = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
UploadStatus = table.Column<int>(type: "integer", nullable: false),
LastUploadProvider = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
RemoteVideoPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
RemoteDanmakuPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
LastUploadedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
UploadErrorMessage = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DeletedLocalFilesAfterUpload = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RecordResults", x => x.Id);
table.ForeignKey(
name: "FK_RecordResults_RecordTasks_RecordTaskId",
column: x => x.RecordTaskId,
principalTable: "RecordTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppSettings_Key",
table: "AppSettings",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LiveRooms_Platform_RoomId",
table: "LiveRooms",
columns: new[] { "Platform", "RoomId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RecordResults_RecordTaskId",
table: "RecordResults",
column: "RecordTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RecordSessions_LiveRoomId",
table: "RecordSessions",
column: "LiveRoomId");
migrationBuilder.CreateIndex(
name: "IX_RecordTasks_LiveRoomId",
table: "RecordTasks",
column: "LiveRoomId");
migrationBuilder.CreateIndex(
name: "IX_RecordTasks_RecordSessionId_SegmentIndex",
table: "RecordTasks",
columns: new[] { "RecordSessionId", "SegmentIndex" });
migrationBuilder.CreateIndex(
name: "IX_SystemLogEntries_CreatedAt",
table: "SystemLogEntries",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_SystemLogEntries_RecordSessionId",
table: "SystemLogEntries",
column: "RecordSessionId");
migrationBuilder.CreateIndex(
name: "IX_UserAccounts_Username",
table: "UserAccounts",
column: "Username",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSessions_Token",
table: "UserSessions",
column: "Token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSessions_UserAccountId",
table: "UserSessions",
column: "UserAccountId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppSettings");
migrationBuilder.DropTable(
name: "RecordResults");
migrationBuilder.DropTable(
name: "SystemLogEntries");
migrationBuilder.DropTable(
name: "UserSessions");
migrationBuilder.DropTable(
name: "RecordTasks");
migrationBuilder.DropTable(
name: "UserAccounts");
migrationBuilder.DropTable(
name: "RecordSessions");
migrationBuilder.DropTable(
name: "LiveRooms");
}
}
}

View File

@ -0,0 +1,581 @@
// <auto-generated />
using System;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LiveRecorderDbContext))]
partial class LiveRecorderDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("LiveRecorder.Domain.Entities.AppSetting", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("AppSettings", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Alias")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("AvailabilityStatus")
.HasColumnType("integer");
b.Property<string>("AvatarUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool?>("DanmakuIncludeNonChatEventsOverride")
.HasColumnType("boolean");
b.Property<int?>("DanmakuMinPollIntervalMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("DanmakuRetryDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<bool?>("EnableAutoReconnectOverride")
.HasColumnType("boolean");
b.Property<bool?>("EnableDanmakuRecordingOverride")
.HasColumnType("boolean");
b.Property<bool>("HasSentLiveNotificationForCurrentSession")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsPinned")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsPriority")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastAutoStartDecisionAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastAutoStartDecisionCode")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LastAutoStartDecisionDetail")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("LastAutoStartDecisionSummary")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastCheckedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastStartRecordingTriggeredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int?>("OutputFormatOverride")
.HasColumnType("integer");
b.Property<int>("Platform")
.HasColumnType("integer");
b.Property<int?>("PollingIntervalSecondsOverride")
.HasColumnType("integer");
b.Property<string>("PreferredQualityOverride")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ReadWriteTimeoutMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("ReconnectDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<int?>("RecordingTemplateOverride")
.HasColumnType("integer");
b.Property<string>("Remark")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int?>("SaveModeOverride")
.HasColumnType("integer");
b.Property<int?>("SegmentDurationMinutesOverride")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Platform", "RoomId")
.IsUnique();
b.ToTable("LiveRooms", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DanmakuFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DanmakuMessageCount")
.HasColumnType("integer");
b.Property<bool>("DeletedLocalFilesAfterUpload")
.HasColumnType("boolean");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<long?>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<int>("FinalStatus")
.HasColumnType("integer");
b.Property<string>("LastUploadProvider")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset?>("LastUploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RecordTaskId")
.HasColumnType("uuid");
b.Property<string>("RemoteDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RemoteVideoPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("UploadErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("UploadStatus")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RecordTaskId")
.IsUnique();
b.ToTable("RecordResults", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("ActiveSegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("OutputPathPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SaveMode")
.HasColumnType("integer");
b.Property<int>("SegmentCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.ToTable("RecordSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("OutputFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("RecordSessionId")
.HasColumnType("uuid");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.HasIndex("RecordSessionId", "SegmentIndex");
b.ToTable("RecordTasks", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<Guid?>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("RecordSessionId")
.HasColumnType("uuid");
b.Property<Guid?>("RecordTaskId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("RecordSessionId");
b.ToTable("SystemLogEntries", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("UserAccounts", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserAccountId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
.WithOne("Result")
.HasForeignKey("LiveRecorder.Domain.Entities.RecordResult", "RecordTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecordTask");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany()
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany("RecordTasks")
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiveRecorder.Domain.Entities.RecordSession", "RecordSession")
.WithMany("RecordTasks")
.HasForeignKey("RecordSessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
b.Navigation("RecordSession");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("UserAccount");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Navigation("Result");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -304,11 +304,10 @@ public sealed class SystemLogRepository : ISystemLogRepository
(item.Detail != null && item.Detail.Contains(content)));
}
var items = await query.ToListAsync(cancellationToken);
return items
return await query
.OrderByDescending(static item => item.CreatedAt)
.Take(Math.Clamp(take, 1, 500))
.ToList();
.ToListAsync(cancellationToken);
}
public void RemoveRange(IEnumerable<SystemLogEntry> entries) => _dbContext.SystemLogEntries.RemoveRange(entries);

View File

@ -0,0 +1,617 @@
using System.Data;
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Persistence;
public sealed class SqliteToPostgresMigrationService
{
private static readonly string[] MigrationTableOrder =
[
"AppSettings",
"UserAccounts",
"UserSessions",
"LiveRooms",
"RecordSessions",
"RecordTasks",
"RecordResults",
"SystemLogEntries"
];
private readonly LiveRecorderDbContext _dbContext;
private readonly ILogger<SqliteToPostgresMigrationService> _logger;
public SqliteToPostgresMigrationService(
LiveRecorderDbContext dbContext,
ILogger<SqliteToPostgresMigrationService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<SqliteMigrationReport> MigrateAsync(
string sqliteDbPath,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sqliteDbPath))
{
throw new ArgumentException("SQLite database path is required.", nameof(sqliteDbPath));
}
var fullPath = Path.GetFullPath(sqliteDbPath);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException("SQLite database file was not found.", fullPath);
}
await EnsureTargetDatabaseIsEmptyAsync(cancellationToken);
await using var sqliteConnection = CreateReadOnlySqliteConnection(fullPath);
await sqliteConnection.OpenAsync(cancellationToken);
await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
var postgresConnection = _dbContext.Database.GetDbConnection();
if (postgresConnection.State != ConnectionState.Open)
{
await postgresConnection.OpenAsync(cancellationToken);
}
var targetTransaction = transaction.GetDbTransaction();
var sourceCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var tableName in MigrationTableOrder)
{
sourceCounts[tableName] = tableName switch
{
"AppSettings" => await ImportAppSettingsAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
"UserAccounts" => await ImportUserAccountsAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
"UserSessions" => await ImportUserSessionsAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
"LiveRooms" => await ImportLiveRoomsAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
"RecordSessions" => await ImportRecordSessionsAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
"RecordTasks" => await ImportRecordTasksAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
"RecordResults" => await ImportRecordResultsAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
"SystemLogEntries" => await ImportSystemLogEntriesAsync(sqliteConnection, postgresConnection, targetTransaction, cancellationToken),
_ => throw new InvalidOperationException($"Unsupported migration table: {tableName}")
};
}
var targetCounts = await ReadTargetCountsAsync(cancellationToken);
foreach (var tableName in MigrationTableOrder)
{
var sourceCount = sourceCounts[tableName];
var targetCount = (int)targetCounts[tableName];
if (sourceCount != targetCount)
{
throw new InvalidOperationException(
$"Migration count mismatch for {tableName}. Source={sourceCount}; Target={targetCount}.");
}
}
await transaction.CommitAsync(cancellationToken);
_logger.LogInformation(
"SQLite to PostgreSQL migration completed successfully. Source={SourcePath}; Tables={TableCount}",
fullPath,
sourceCounts.Count);
return new SqliteMigrationReport(fullPath, sourceCounts, targetCounts);
}
private async Task EnsureTargetDatabaseIsEmptyAsync(CancellationToken cancellationToken)
{
var counts = await ReadTargetCountsAsync(cancellationToken);
var nonEmpty = counts.Where(static item => item.Value > 0).ToArray();
if (nonEmpty.Length == 0)
{
return;
}
var detail = string.Join(", ", nonEmpty.Select(static item => $"{item.Key}={item.Value}"));
throw new InvalidOperationException(
$"The target PostgreSQL database is not empty. Refusing to import into a non-empty database. {detail}");
}
private async Task<Dictionary<string, long>> ReadTargetCountsAsync(CancellationToken cancellationToken)
{
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
["AppSettings"] = await _dbContext.AppSettings.LongCountAsync(cancellationToken),
["UserAccounts"] = await _dbContext.UserAccounts.LongCountAsync(cancellationToken),
["UserSessions"] = await _dbContext.UserSessions.LongCountAsync(cancellationToken),
["LiveRooms"] = await _dbContext.LiveRooms.LongCountAsync(cancellationToken),
["RecordSessions"] = await _dbContext.RecordSessions.LongCountAsync(cancellationToken),
["RecordTasks"] = await _dbContext.RecordTasks.LongCountAsync(cancellationToken),
["RecordResults"] = await _dbContext.RecordResults.LongCountAsync(cancellationToken),
["SystemLogEntries"] = await _dbContext.SystemLogEntries.LongCountAsync(cancellationToken)
};
}
private static SqliteConnection CreateReadOnlySqliteConnection(string sqliteDbPath)
{
var builder = new SqliteConnectionStringBuilder
{
DataSource = sqliteDbPath,
Mode = SqliteOpenMode.ReadOnly,
Pooling = false
};
return new SqliteConnection(builder.ToString());
}
private static async Task<int> ImportAppSettingsAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT Id, Key, Value, UpdatedAt
FROM AppSettings
ORDER BY Key;
""";
const string insertSql = """
INSERT INTO "AppSettings" ("Id", "Key", "Value", "UpdatedAt")
VALUES (@Id, @Key, @Value, @UpdatedAt);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
SetParameter(command, "@Id", GetGuid(reader, 0));
SetParameter(command, "@Key", reader.GetString(1));
SetParameter(command, "@Value", reader.IsDBNull(2) ? string.Empty : reader.GetString(2));
SetParameter(command, "@UpdatedAt", GetDateTimeOffset(reader, 3));
},
cancellationToken);
}
private static async Task<int> ImportUserAccountsAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT Id, Username, DisplayName, PasswordHash, IsActive, CreatedAt
FROM UserAccounts
ORDER BY Username;
""";
const string insertSql = """
INSERT INTO "UserAccounts" ("Id", "Username", "DisplayName", "PasswordHash", "IsActive", "CreatedAt")
VALUES (@Id, @Username, @DisplayName, @PasswordHash, @IsActive, @CreatedAt);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
SetParameter(command, "@Id", GetGuid(reader, 0));
SetParameter(command, "@Username", reader.GetString(1));
SetParameter(command, "@DisplayName", reader.GetString(2));
SetParameter(command, "@PasswordHash", reader.GetString(3));
SetParameter(command, "@IsActive", GetBoolean(reader, 4));
SetParameter(command, "@CreatedAt", GetDateTimeOffset(reader, 5));
},
cancellationToken);
}
private static async Task<int> ImportUserSessionsAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT Id, UserAccountId, Token, ExpiresAt, CreatedAt, RevokedAt
FROM UserSessions
ORDER BY CreatedAt;
""";
const string insertSql = """
INSERT INTO "UserSessions" ("Id", "UserAccountId", "Token", "ExpiresAt", "CreatedAt", "RevokedAt")
VALUES (@Id, @UserAccountId, @Token, @ExpiresAt, @CreatedAt, @RevokedAt);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
SetParameter(command, "@Id", GetGuid(reader, 0));
SetParameter(command, "@UserAccountId", GetGuid(reader, 1));
SetParameter(command, "@Token", reader.GetString(2));
SetParameter(command, "@ExpiresAt", GetDateTimeOffset(reader, 3));
SetParameter(command, "@CreatedAt", GetDateTimeOffset(reader, 4));
SetParameter(command, "@RevokedAt", GetNullableDateTimeOffset(reader, 5));
},
cancellationToken);
}
private static async Task<int> ImportLiveRoomsAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT
Id, Platform, SourceUrl, RoomId, NormalizedUrl, Title, AnchorName, AnchorId, AvatarUrl, CoverUrl,
Remark, IsPinned, Alias, IsPriority, PollingIntervalSecondsOverride,
PreferredQualityOverride, OutputFormatOverride, SaveModeOverride, RecordingTemplateOverride,
SegmentDurationMinutesOverride, EnableAutoReconnectOverride, ReconnectDelayMaxSecondsOverride,
ReadWriteTimeoutMillisecondsOverride, EnableDanmakuRecordingOverride, DanmakuIncludeNonChatEventsOverride,
DanmakuMinPollIntervalMillisecondsOverride, DanmakuRetryDelayMaxSecondsOverride,
IsEnabled, HasSentLiveNotificationForCurrentSession, AvailabilityStatus,
LastAutoStartDecisionCode, LastAutoStartDecisionSummary, LastAutoStartDecisionDetail, LastAutoStartDecisionAt,
CreatedAt, UpdatedAt, LastCheckedAt, LastStartRecordingTriggeredAt
FROM LiveRooms
ORDER BY UpdatedAt DESC;
""";
const string insertSql = """
INSERT INTO "LiveRooms" (
"Id", "Platform", "SourceUrl", "RoomId", "NormalizedUrl", "Title", "AnchorName", "AnchorId", "AvatarUrl", "CoverUrl",
"Remark", "IsPinned", "Alias", "IsPriority", "PollingIntervalSecondsOverride",
"PreferredQualityOverride", "OutputFormatOverride", "SaveModeOverride", "RecordingTemplateOverride",
"SegmentDurationMinutesOverride", "EnableAutoReconnectOverride", "ReconnectDelayMaxSecondsOverride",
"ReadWriteTimeoutMillisecondsOverride", "EnableDanmakuRecordingOverride", "DanmakuIncludeNonChatEventsOverride",
"DanmakuMinPollIntervalMillisecondsOverride", "DanmakuRetryDelayMaxSecondsOverride",
"IsEnabled", "HasSentLiveNotificationForCurrentSession", "AvailabilityStatus",
"LastAutoStartDecisionCode", "LastAutoStartDecisionSummary", "LastAutoStartDecisionDetail", "LastAutoStartDecisionAt",
"CreatedAt", "UpdatedAt", "LastCheckedAt", "LastStartRecordingTriggeredAt"
) VALUES (
@Id, @Platform, @SourceUrl, @RoomId, @NormalizedUrl, @Title, @AnchorName, @AnchorId, @AvatarUrl, @CoverUrl,
@Remark, @IsPinned, @Alias, @IsPriority, @PollingIntervalSecondsOverride,
@PreferredQualityOverride, @OutputFormatOverride, @SaveModeOverride, @RecordingTemplateOverride,
@SegmentDurationMinutesOverride, @EnableAutoReconnectOverride, @ReconnectDelayMaxSecondsOverride,
@ReadWriteTimeoutMillisecondsOverride, @EnableDanmakuRecordingOverride, @DanmakuIncludeNonChatEventsOverride,
@DanmakuMinPollIntervalMillisecondsOverride, @DanmakuRetryDelayMaxSecondsOverride,
@IsEnabled, @HasSentLiveNotificationForCurrentSession, @AvailabilityStatus,
@LastAutoStartDecisionCode, @LastAutoStartDecisionSummary, @LastAutoStartDecisionDetail, @LastAutoStartDecisionAt,
@CreatedAt, @UpdatedAt, @LastCheckedAt, @LastStartRecordingTriggeredAt
);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
var i = 0;
SetParameter(command, "@Id", GetGuid(reader, i++));
SetParameter(command, "@Platform", reader.GetInt32(i++));
SetParameter(command, "@SourceUrl", reader.GetString(i++));
SetParameter(command, "@RoomId", reader.GetString(i++));
SetParameter(command, "@NormalizedUrl", reader.GetString(i++));
SetParameter(command, "@Title", GetNullableString(reader, i++));
SetParameter(command, "@AnchorName", GetNullableString(reader, i++));
SetParameter(command, "@AnchorId", GetNullableString(reader, i++));
SetParameter(command, "@AvatarUrl", GetNullableString(reader, i++));
SetParameter(command, "@CoverUrl", GetNullableString(reader, i++));
SetParameter(command, "@Remark", GetNullableString(reader, i++));
SetParameter(command, "@IsPinned", GetBoolean(reader, i++));
SetParameter(command, "@Alias", GetNullableString(reader, i++));
SetParameter(command, "@IsPriority", GetBoolean(reader, i++));
SetParameter(command, "@PollingIntervalSecondsOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@PreferredQualityOverride", GetNullableString(reader, i++));
SetParameter(command, "@OutputFormatOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@SaveModeOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@RecordingTemplateOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@SegmentDurationMinutesOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@EnableAutoReconnectOverride", GetNullableBoolean(reader, i++));
SetParameter(command, "@ReconnectDelayMaxSecondsOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@ReadWriteTimeoutMillisecondsOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@EnableDanmakuRecordingOverride", GetNullableBoolean(reader, i++));
SetParameter(command, "@DanmakuIncludeNonChatEventsOverride", GetNullableBoolean(reader, i++));
SetParameter(command, "@DanmakuMinPollIntervalMillisecondsOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@DanmakuRetryDelayMaxSecondsOverride", GetNullableInt32(reader, i++));
SetParameter(command, "@IsEnabled", GetBoolean(reader, i++));
SetParameter(command, "@HasSentLiveNotificationForCurrentSession", GetBoolean(reader, i++));
SetParameter(command, "@AvailabilityStatus", reader.GetInt32(i++));
SetParameter(command, "@LastAutoStartDecisionCode", GetNullableString(reader, i++));
SetParameter(command, "@LastAutoStartDecisionSummary", GetNullableString(reader, i++));
SetParameter(command, "@LastAutoStartDecisionDetail", GetNullableString(reader, i++));
SetParameter(command, "@LastAutoStartDecisionAt", GetNullableDateTimeOffset(reader, i++));
SetParameter(command, "@CreatedAt", GetDateTimeOffset(reader, i++));
SetParameter(command, "@UpdatedAt", GetDateTimeOffset(reader, i++));
SetParameter(command, "@LastCheckedAt", GetNullableDateTimeOffset(reader, i++));
SetParameter(command, "@LastStartRecordingTriggeredAt", GetNullableDateTimeOffset(reader, i++));
},
cancellationToken);
}
private static async Task<int> ImportRecordSessionsAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT
Id, LiveRoomId, Status, PreferredQuality, OutputFormat, SaveMode, StreamUrl, OutputPathPattern,
ActiveSegmentIndex, SegmentCount, RecorderProcessId, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt
FROM RecordSessions
ORDER BY CreatedAt;
""";
const string insertSql = """
INSERT INTO "RecordSessions" (
"Id", "LiveRoomId", "Status", "PreferredQuality", "OutputFormat", "SaveMode", "StreamUrl", "OutputPathPattern",
"ActiveSegmentIndex", "SegmentCount", "RecorderProcessId", "ErrorMessage", "CreatedAt", "UpdatedAt", "StartedAt", "EndedAt"
) VALUES (
@Id, @LiveRoomId, @Status, @PreferredQuality, @OutputFormat, @SaveMode, @StreamUrl, @OutputPathPattern,
@ActiveSegmentIndex, @SegmentCount, @RecorderProcessId, @ErrorMessage, @CreatedAt, @UpdatedAt, @StartedAt, @EndedAt
);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
var i = 0;
SetParameter(command, "@Id", GetGuid(reader, i++));
SetParameter(command, "@LiveRoomId", GetGuid(reader, i++));
SetParameter(command, "@Status", reader.GetInt32(i++));
SetParameter(command, "@PreferredQuality", reader.GetString(i++));
SetParameter(command, "@OutputFormat", reader.GetInt32(i++));
SetParameter(command, "@SaveMode", reader.GetInt32(i++));
SetParameter(command, "@StreamUrl", GetNullableString(reader, i++));
SetParameter(command, "@OutputPathPattern", GetNullableString(reader, i++));
SetParameter(command, "@ActiveSegmentIndex", reader.GetInt32(i++));
SetParameter(command, "@SegmentCount", reader.GetInt32(i++));
SetParameter(command, "@RecorderProcessId", GetNullableInt32(reader, i++));
SetParameter(command, "@ErrorMessage", GetNullableString(reader, i++));
SetParameter(command, "@CreatedAt", GetDateTimeOffset(reader, i++));
SetParameter(command, "@UpdatedAt", GetDateTimeOffset(reader, i++));
SetParameter(command, "@StartedAt", GetNullableDateTimeOffset(reader, i++));
SetParameter(command, "@EndedAt", GetNullableDateTimeOffset(reader, i++));
},
cancellationToken);
}
private static async Task<int> ImportRecordTasksAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT
Id, LiveRoomId, RecordSessionId, SegmentIndex, Status, PreferredQuality, OutputFormat, StreamUrl, OutputFilePath,
RecorderProcessId, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt, DurationSeconds
FROM RecordTasks
ORDER BY CreatedAt;
""";
const string insertSql = """
INSERT INTO "RecordTasks" (
"Id", "LiveRoomId", "RecordSessionId", "SegmentIndex", "Status", "PreferredQuality", "OutputFormat", "StreamUrl", "OutputFilePath",
"RecorderProcessId", "ErrorMessage", "CreatedAt", "UpdatedAt", "StartedAt", "EndedAt", "DurationSeconds"
) VALUES (
@Id, @LiveRoomId, @RecordSessionId, @SegmentIndex, @Status, @PreferredQuality, @OutputFormat, @StreamUrl, @OutputFilePath,
@RecorderProcessId, @ErrorMessage, @CreatedAt, @UpdatedAt, @StartedAt, @EndedAt, @DurationSeconds
);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
var i = 0;
var recordSessionId = GetNullableGuid(reader, i + 2)
?? throw new InvalidOperationException("RecordTasks contains rows without RecordSessionId. Please repair the SQLite database before migration.");
SetParameter(command, "@Id", GetGuid(reader, i++));
SetParameter(command, "@LiveRoomId", GetGuid(reader, i++));
SetParameter(command, "@RecordSessionId", recordSessionId);
i++;
SetParameter(command, "@SegmentIndex", reader.GetInt32(i++));
SetParameter(command, "@Status", reader.GetInt32(i++));
SetParameter(command, "@PreferredQuality", reader.GetString(i++));
SetParameter(command, "@OutputFormat", reader.GetInt32(i++));
SetParameter(command, "@StreamUrl", GetNullableString(reader, i++));
SetParameter(command, "@OutputFilePath", GetNullableString(reader, i++));
SetParameter(command, "@RecorderProcessId", GetNullableInt32(reader, i++));
SetParameter(command, "@ErrorMessage", GetNullableString(reader, i++));
SetParameter(command, "@CreatedAt", GetDateTimeOffset(reader, i++));
SetParameter(command, "@UpdatedAt", GetDateTimeOffset(reader, i++));
SetParameter(command, "@StartedAt", GetNullableDateTimeOffset(reader, i++));
SetParameter(command, "@EndedAt", GetNullableDateTimeOffset(reader, i++));
SetParameter(command, "@DurationSeconds", GetNullableDouble(reader, i++));
},
cancellationToken);
}
private static async Task<int> ImportRecordResultsAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT
Id, RecordTaskId, FilePath, FileSizeBytes, DurationSeconds, DanmakuFilePath, DanmakuMessageCount, FinalStatus,
ErrorMessage, UploadStatus, LastUploadProvider, RemoteVideoPath, RemoteDanmakuPath, LastUploadedAt,
UploadErrorMessage, DeletedLocalFilesAfterUpload, CreatedAt
FROM RecordResults
ORDER BY CreatedAt;
""";
const string insertSql = """
INSERT INTO "RecordResults" (
"Id", "RecordTaskId", "FilePath", "FileSizeBytes", "DurationSeconds", "DanmakuFilePath", "DanmakuMessageCount", "FinalStatus",
"ErrorMessage", "UploadStatus", "LastUploadProvider", "RemoteVideoPath", "RemoteDanmakuPath", "LastUploadedAt",
"UploadErrorMessage", "DeletedLocalFilesAfterUpload", "CreatedAt"
) VALUES (
@Id, @RecordTaskId, @FilePath, @FileSizeBytes, @DurationSeconds, @DanmakuFilePath, @DanmakuMessageCount, @FinalStatus,
@ErrorMessage, @UploadStatus, @LastUploadProvider, @RemoteVideoPath, @RemoteDanmakuPath, @LastUploadedAt,
@UploadErrorMessage, @DeletedLocalFilesAfterUpload, @CreatedAt
);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
var i = 0;
SetParameter(command, "@Id", GetGuid(reader, i++));
SetParameter(command, "@RecordTaskId", GetGuid(reader, i++));
SetParameter(command, "@FilePath", reader.GetString(i++));
SetParameter(command, "@FileSizeBytes", GetNullableInt64(reader, i++));
SetParameter(command, "@DurationSeconds", GetNullableDouble(reader, i++));
SetParameter(command, "@DanmakuFilePath", GetNullableString(reader, i++));
SetParameter(command, "@DanmakuMessageCount", reader.GetInt32(i++));
SetParameter(command, "@FinalStatus", reader.GetInt32(i++));
SetParameter(command, "@ErrorMessage", GetNullableString(reader, i++));
SetParameter(command, "@UploadStatus", reader.GetInt32(i++));
SetParameter(command, "@LastUploadProvider", GetNullableString(reader, i++));
SetParameter(command, "@RemoteVideoPath", GetNullableString(reader, i++));
SetParameter(command, "@RemoteDanmakuPath", GetNullableString(reader, i++));
SetParameter(command, "@LastUploadedAt", GetNullableDateTimeOffset(reader, i++));
SetParameter(command, "@UploadErrorMessage", GetNullableString(reader, i++));
SetParameter(command, "@DeletedLocalFilesAfterUpload", GetBoolean(reader, i++));
SetParameter(command, "@CreatedAt", GetDateTimeOffset(reader, i++));
},
cancellationToken);
}
private static async Task<int> ImportSystemLogEntriesAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
CancellationToken cancellationToken)
{
const string selectSql = """
SELECT Id, Level, Category, Message, Detail, LiveRoomId, RecordSessionId, RecordTaskId, CreatedAt
FROM SystemLogEntries
ORDER BY CreatedAt;
""";
const string insertSql = """
INSERT INTO "SystemLogEntries" (
"Id", "Level", "Category", "Message", "Detail", "LiveRoomId", "RecordSessionId", "RecordTaskId", "CreatedAt"
) VALUES (
@Id, @Level, @Category, @Message, @Detail, @LiveRoomId, @RecordSessionId, @RecordTaskId, @CreatedAt
);
""";
return await CopyRowsAsync(
source,
target,
transaction,
selectSql,
insertSql,
static (reader, command) =>
{
var i = 0;
SetParameter(command, "@Id", GetGuid(reader, i++));
SetParameter(command, "@Level", reader.GetInt32(i++));
SetParameter(command, "@Category", reader.GetString(i++));
SetParameter(command, "@Message", reader.GetString(i++));
SetParameter(command, "@Detail", GetNullableString(reader, i++));
SetParameter(command, "@LiveRoomId", GetNullableGuid(reader, i++));
SetParameter(command, "@RecordSessionId", GetNullableGuid(reader, i++));
SetParameter(command, "@RecordTaskId", GetNullableGuid(reader, i++));
SetParameter(command, "@CreatedAt", GetDateTimeOffset(reader, i++));
},
cancellationToken);
}
private static async Task<int> CopyRowsAsync(
SqliteConnection source,
DbConnection target,
DbTransaction transaction,
string selectSql,
string insertSql,
Action<SqliteDataReader, DbCommand> bindValues,
CancellationToken cancellationToken)
{
await using var selectCommand = source.CreateCommand();
selectCommand.CommandText = selectSql;
await using var reader = await selectCommand.ExecuteReaderAsync(cancellationToken);
await using var insertCommand = target.CreateCommand();
insertCommand.Transaction = transaction;
insertCommand.CommandText = insertSql;
var count = 0;
while (await reader.ReadAsync(cancellationToken))
{
insertCommand.Parameters.Clear();
bindValues(reader, insertCommand);
await insertCommand.ExecuteNonQueryAsync(cancellationToken);
count++;
}
return count;
}
private static void SetParameter(DbCommand command, string name, object? value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.Value = value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
private static Guid GetGuid(SqliteDataReader reader, int index) =>
Guid.Parse(reader.GetString(index));
private static Guid? GetNullableGuid(SqliteDataReader reader, int index) =>
reader.IsDBNull(index) ? null : Guid.Parse(reader.GetString(index));
private static string? GetNullableString(SqliteDataReader reader, int index) =>
reader.IsDBNull(index) ? null : reader.GetString(index);
private static DateTimeOffset GetDateTimeOffset(SqliteDataReader reader, int index) =>
DateTimeOffset.Parse(reader.GetString(index), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
private static DateTimeOffset? GetNullableDateTimeOffset(SqliteDataReader reader, int index) =>
reader.IsDBNull(index)
? null
: DateTimeOffset.Parse(reader.GetString(index), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
private static bool GetBoolean(SqliteDataReader reader, int index) =>
reader.GetInt64(index) != 0;
private static bool? GetNullableBoolean(SqliteDataReader reader, int index) =>
reader.IsDBNull(index) ? null : reader.GetInt64(index) != 0;
private static int? GetNullableInt32(SqliteDataReader reader, int index) =>
reader.IsDBNull(index) ? null : reader.GetInt32(index);
private static long? GetNullableInt64(SqliteDataReader reader, int index) =>
reader.IsDBNull(index) ? null : reader.GetInt64(index);
private static double? GetNullableDouble(SqliteDataReader reader, int index) =>
reader.IsDBNull(index) ? null : reader.GetDouble(index);
}
public sealed record SqliteMigrationReport(
string SourcePath,
IReadOnlyDictionary<string, int> SourceCounts,
IReadOnlyDictionary<string, long> TargetCounts);

View File

@ -848,21 +848,21 @@ public sealed partial class FfmpegService
var finalStatus = (int)recordTask.Status;
// Multiple background paths can reconcile the same segment after ffmpeg exits.
// Use SQLite's atomic upsert instead of EF Add-or-Update to avoid RecordTaskId
// unique constraint races between scoped DbContext instances.
// Use the database's atomic upsert instead of EF Add-or-Update to avoid
// RecordTaskId unique constraint races between scoped DbContext instances.
await dbContext.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO RecordResults
(Id, RecordTaskId, FilePath, FileSizeBytes, DurationSeconds, DanmakuFilePath, DanmakuMessageCount, FinalStatus, ErrorMessage, CreatedAt)
INSERT INTO "RecordResults"
("Id", "RecordTaskId", "FilePath", "FileSizeBytes", "DurationSeconds", "DanmakuFilePath", "DanmakuMessageCount", "FinalStatus", "ErrorMessage", "CreatedAt")
VALUES
({resultId}, {recordTask.Id}, {effectiveOutputPath}, {fileSize}, {durationSeconds}, {danmakuFilePath}, {normalizedDanmakuCount}, {finalStatus}, {recordTask.ErrorMessage}, {endedAt})
ON CONFLICT(RecordTaskId) DO UPDATE SET
FilePath = excluded.FilePath,
FileSizeBytes = excluded.FileSizeBytes,
DurationSeconds = excluded.DurationSeconds,
DanmakuFilePath = excluded.DanmakuFilePath,
DanmakuMessageCount = excluded.DanmakuMessageCount,
FinalStatus = excluded.FinalStatus,
ErrorMessage = excluded.ErrorMessage;
ON CONFLICT("RecordTaskId") DO UPDATE SET
"FilePath" = excluded."FilePath",
"FileSizeBytes" = excluded."FileSizeBytes",
"DurationSeconds" = excluded."DurationSeconds",
"DanmakuFilePath" = excluded."DanmakuFilePath",
"DanmakuMessageCount" = excluded."DanmakuMessageCount",
"FinalStatus" = excluded."FinalStatus",
"ErrorMessage" = excluded."ErrorMessage";
""", cancellationToken);
}

View File

@ -11,11 +11,11 @@ using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace LiveRecorder.Infrastructure.Services;
@ -637,13 +637,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
}
if (exception is DbUpdateException dbUpdateException &&
IsSqliteLockException(dbUpdateException))
IsTransientDatabaseException(dbUpdateException))
{
return true;
}
if (exception is SqliteException sqliteException &&
IsSqliteLockException(sqliteException))
if (exception is NpgsqlException npgsqlException &&
npgsqlException.IsTransient)
{
return true;
}
@ -691,9 +691,9 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
private static string BuildExceptionEmailKey(string scope, Exception exception, Guid? liveRoomId = null)
{
if (IsSqliteStorageFullException(exception))
if (IsDatabaseStorageFullException(exception))
{
return $"{scope}:sqlite-storage-full";
return $"{scope}:database-storage-full";
}
var root = exception.GetBaseException();
@ -701,16 +701,21 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
return $"{scope}:{liveRoomId?.ToString() ?? "global"}:{root.GetType().FullName}:{message}";
}
private static bool IsSqliteStorageFullException(Exception exception)
private static bool IsDatabaseStorageFullException(Exception exception)
{
if (exception is SqliteException sqliteException &&
(sqliteException.SqliteErrorCode == 13 ||
sqliteException.Message.Contains("database or disk is full", StringComparison.OrdinalIgnoreCase)))
if (exception is PostgresException postgresException &&
postgresException.SqlState == PostgresErrorCodes.DiskFull)
{
return true;
}
return exception.InnerException is not null && IsSqliteStorageFullException(exception.InnerException);
if (exception is IOException ioException &&
ioException.Message.Contains("no space left on device", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return exception.InnerException is not null && IsDatabaseStorageFullException(exception.InnerException);
}
private static async Task UpdateAutoStartDecisionAsync(
@ -738,21 +743,15 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
await dbContext.SaveChangesAsync(cancellationToken);
return;
}
catch (DbUpdateException ex) when (attempt < 5 && IsSqliteLockException(ex))
catch (DbUpdateException ex) when (attempt < 5 && IsTransientDatabaseException(ex))
{
await Task.Delay(TimeSpan.FromMilliseconds(300 * Math.Pow(2, attempt - 1)), cancellationToken);
}
}
}
private static bool IsSqliteLockException(DbUpdateException exception) =>
exception.InnerException is SqliteException sqliteException &&
IsSqliteLockException(sqliteException);
private static bool IsSqliteLockException(SqliteException exception) =>
exception.SqliteErrorCode is 5 or 6 ||
exception.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) ||
exception.Message.Contains("database table is locked", StringComparison.OrdinalIgnoreCase);
private static bool IsTransientDatabaseException(DbUpdateException exception) =>
exception.InnerException is NpgsqlException npgsqlException && npgsqlException.IsTransient;
private static string? Truncate(string? value, int maxLength)
{

View File

@ -26,7 +26,7 @@ WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ConnectionStrings__DefaultConnection=Data Source=/app/data/live-recorder.db
ENV ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=live_recorder;Username=postgres;Password=postgres
COPY --from=build /app/publish ./

View File

@ -6,6 +6,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>

View File

@ -21,12 +21,12 @@ using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
using LiveRecorder.Infrastructure.Platforms.Huya;
using LiveRecorder.Infrastructure.Services;
using LiveRecorder.WebApi.Middleware;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
var resetRecordingData = args.Contains("--reset-recording-data", StringComparer.OrdinalIgnoreCase);
var migrateSqlitePath = GetOptionValue(args, "--migrate-sqlite");
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? ["http://localhost:5173"];
builder.Services.AddControllers();
@ -111,16 +111,12 @@ builder.Services.AddHttpClient("bilibili", client =>
}
});
var sqliteConnectionStringBuilder = new SqliteConnectionStringBuilder(
builder.Configuration.GetConnectionString("DefaultConnection"))
{
Cache = SqliteCacheMode.Shared,
Mode = SqliteOpenMode.ReadWriteCreate,
DefaultTimeout = 30
};
var defaultConnection = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required.");
builder.Services.AddDbContext<LiveRecorderDbContext>(options =>
options.UseSqlite(sqliteConnectionStringBuilder.ToString()));
options.UseNpgsql(defaultConnection, npgsql =>
npgsql.MigrationsAssembly(typeof(LiveRecorderDbContext).Assembly.FullName)));
builder.Services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>());
builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>();
@ -150,6 +146,7 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
builder.Services.AddScoped<RecordUploadService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
builder.Services.AddSingleton<BilibiliWbiSigner>();
builder.Services.AddScoped<BilibiliHttpClient>();
@ -183,11 +180,25 @@ app.UseMiddleware<ApiTokenAuthenticationMiddleware>();
app.MapGet("/", () => Results.Redirect("/swagger"));
app.MapControllers();
if (!resetRecordingData)
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await initializer.InitializeAsync(seedDefaults: migrateSqlitePath is null, cancellationToken: default);
}
if (!string.IsNullOrWhiteSpace(migrateSqlitePath))
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await initializer.InitializeAsync();
var migrator = scope.ServiceProvider.GetRequiredService<SqliteToPostgresMigrationService>();
var report = await migrator.MigrateAsync(migrateSqlitePath);
Console.WriteLine($"SQLite migration completed. Source={report.SourcePath}");
foreach (var tableName in report.SourceCounts.Keys.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($"{tableName}: {report.SourceCounts[tableName]} -> {report.TargetCounts[tableName]}");
}
return;
}
if (resetRecordingData)
@ -196,16 +207,15 @@ if (resetRecordingData)
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var before = await ReadRecordingDataCountsAsync(dbContext);
await using (var transaction = await dbContext.Database.BeginTransactionAsync())
{
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM SystemLogEntries WHERE LiveRoomId IS NOT NULL OR RecordSessionId IS NOT NULL OR RecordTaskId IS NOT NULL;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordResults;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordTasks;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordSessions;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM LiveRooms;");
await transaction.CommitAsync();
}
await using var transaction = await dbContext.Database.BeginTransactionAsync();
await dbContext.SystemLogEntries
.Where(item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null)
.ExecuteDeleteAsync();
await dbContext.RecordResults.ExecuteDeleteAsync();
await dbContext.RecordTasks.ExecuteDeleteAsync();
await dbContext.RecordSessions.ExecuteDeleteAsync();
await dbContext.LiveRooms.ExecuteDeleteAsync();
await transaction.CommitAsync();
var after = await ReadRecordingDataCountsAsync(dbContext);
Console.WriteLine("Recording data reset complete.");
@ -249,3 +259,16 @@ static SocketsHttpHandler CreateDouyinHttpHandler(bool useProxy)
}
};
}
static string? GetOptionValue(string[] args, string optionName)
{
for (var index = 0; index < args.Length - 1; index++)
{
if (string.Equals(args[index], optionName, StringComparison.OrdinalIgnoreCase))
{
return args[index + 1];
}
}
return null;
}

View File

@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=live-recorder.db"
"DefaultConnection": "Host=localhost;Port=5432;Database=live_recorder;Username=postgres;Password=postgres"
},
"Cors": {
"Origins": [