feat: migrate runtime to postgresql
This commit is contained in:
parent
49d893f29b
commit
8132466c5d
@ -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:
|
||||
|
||||
89
docs/postgresql-migration.md
Normal file
89
docs/postgresql-migration.md
Normal file
@ -0,0 +1,89 @@
|
||||
# PostgreSQL 切换与 SQLite 历史数据迁移
|
||||
|
||||
这份说明对应当前主线版本:应用正式运行数据库已经切换为 PostgreSQL,SQLite 仅用于一次性历史数据导入。
|
||||
|
||||
## 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 备份。
|
||||
@ -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}",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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 ./
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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": [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user