feat: migrate runtime to postgresql
This commit is contained in:
+23
-1
@@ -1,4 +1,20 @@
|
|||||||
services:
|
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:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -8,10 +24,13 @@ services:
|
|||||||
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim}
|
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}
|
DOTNET_RUNTIME_IMAGE: ${DOTNET_RUNTIME_IMAGE:-mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_URLS: http://+:8080
|
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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./records:/app/records
|
- ./records:/app/records
|
||||||
@@ -32,3 +51,6 @@ services:
|
|||||||
- api
|
- api
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-8080}:80"
|
- "${HTTP_PORT:-8080}:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
|||||||
@@ -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)
|
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Logging must be best-effort. If SQLite is locked/full, throwing from here
|
// Logging must be best-effort. Throwing from here would turn a log
|
||||||
// causes the scheduler to turn a log persistence failure into an email storm.
|
// persistence hiccup into user-visible scheduling failures and noisy alerts.
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
ex,
|
ex,
|
||||||
"System log write failed. Category={Category}; Message={Message}; LiveRoomId={LiveRoomId}; RecordSessionId={RecordSessionId}; RecordTaskId={RecordTaskId}",
|
"System log write failed. Category={Category}; Message={Message}; LiveRoomId={LiveRoomId}; RecordSessionId={RecordSessionId}; RecordTaskId={RecordTaskId}",
|
||||||
|
|||||||
@@ -10,8 +10,13 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.14" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.14" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using LiveRecorder.Application.Common;
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Domain.Entities;
|
using LiveRecorder.Domain.Entities;
|
||||||
using LiveRecorder.Domain.Enums;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace LiveRecorder.Infrastructure.Persistence;
|
namespace LiveRecorder.Infrastructure.Persistence;
|
||||||
@@ -15,13 +13,17 @@ public sealed class DatabaseInitializer
|
|||||||
_dbContext = dbContext;
|
_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.MigrateAsync(cancellationToken);
|
||||||
await _dbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", cancellationToken);
|
|
||||||
await _dbContext.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;", cancellationToken);
|
if (!seedDefaults)
|
||||||
await EnsureSchemaAsync(cancellationToken);
|
{
|
||||||
await BackfillRecordSessionsAsync(cancellationToken);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken))
|
if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
@@ -79,9 +81,18 @@ public sealed class DatabaseInitializer
|
|||||||
["platform_proxy.huya.enabled"] = "False",
|
["platform_proxy.huya.enabled"] = "False",
|
||||||
["platform_proxy.huya.url"] = string.Empty,
|
["platform_proxy.huya.url"] = string.Empty,
|
||||||
["event_scripts.enabled"] = "False",
|
["event_scripts.enabled"] = "False",
|
||||||
|
["event_scripts.live_started.enabled"] = "False",
|
||||||
["event_scripts.live_started.path"] = string.Empty,
|
["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.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.path"] = string.Empty,
|
||||||
|
["event_scripts.segment_completed.content"] = string.Empty,
|
||||||
|
["event_scripts.segment_completed.mode"] = "Path",
|
||||||
["event_scripts.timeout_seconds"] = "60",
|
["event_scripts.timeout_seconds"] = "60",
|
||||||
["retention.cleanup.enabled"] = "False",
|
["retention.cleanup.enabled"] = "False",
|
||||||
["retention.cleanup.days"] = "30",
|
["retention.cleanup.days"] = "30",
|
||||||
@@ -150,238 +161,4 @@ public sealed class DatabaseInitializer
|
|||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+584
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+325
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+581
@@ -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)));
|
(item.Detail != null && item.Detail.Contains(content)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = await query.ToListAsync(cancellationToken);
|
return await query
|
||||||
return items
|
|
||||||
.OrderByDescending(static item => item.CreatedAt)
|
.OrderByDescending(static item => item.CreatedAt)
|
||||||
.Take(Math.Clamp(take, 1, 500))
|
.Take(Math.Clamp(take, 1, 500))
|
||||||
.ToList();
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveRange(IEnumerable<SystemLogEntry> entries) => _dbContext.SystemLogEntries.RemoveRange(entries);
|
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;
|
var finalStatus = (int)recordTask.Status;
|
||||||
|
|
||||||
// Multiple background paths can reconcile the same segment after ffmpeg exits.
|
// 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
|
// Use the database's atomic upsert instead of EF Add-or-Update to avoid
|
||||||
// unique constraint races between scoped DbContext instances.
|
// RecordTaskId unique constraint races between scoped DbContext instances.
|
||||||
await dbContext.Database.ExecuteSqlInterpolatedAsync($"""
|
await dbContext.Database.ExecuteSqlInterpolatedAsync($"""
|
||||||
INSERT INTO RecordResults
|
INSERT INTO "RecordResults"
|
||||||
(Id, RecordTaskId, FilePath, FileSizeBytes, DurationSeconds, DanmakuFilePath, DanmakuMessageCount, FinalStatus, ErrorMessage, CreatedAt)
|
("Id", "RecordTaskId", "FilePath", "FileSizeBytes", "DurationSeconds", "DanmakuFilePath", "DanmakuMessageCount", "FinalStatus", "ErrorMessage", "CreatedAt")
|
||||||
VALUES
|
VALUES
|
||||||
({resultId}, {recordTask.Id}, {effectiveOutputPath}, {fileSize}, {durationSeconds}, {danmakuFilePath}, {normalizedDanmakuCount}, {finalStatus}, {recordTask.ErrorMessage}, {endedAt})
|
({resultId}, {recordTask.Id}, {effectiveOutputPath}, {fileSize}, {durationSeconds}, {danmakuFilePath}, {normalizedDanmakuCount}, {finalStatus}, {recordTask.ErrorMessage}, {endedAt})
|
||||||
ON CONFLICT(RecordTaskId) DO UPDATE SET
|
ON CONFLICT("RecordTaskId") DO UPDATE SET
|
||||||
FilePath = excluded.FilePath,
|
"FilePath" = excluded."FilePath",
|
||||||
FileSizeBytes = excluded.FileSizeBytes,
|
"FileSizeBytes" = excluded."FileSizeBytes",
|
||||||
DurationSeconds = excluded.DurationSeconds,
|
"DurationSeconds" = excluded."DurationSeconds",
|
||||||
DanmakuFilePath = excluded.DanmakuFilePath,
|
"DanmakuFilePath" = excluded."DanmakuFilePath",
|
||||||
DanmakuMessageCount = excluded.DanmakuMessageCount,
|
"DanmakuMessageCount" = excluded."DanmakuMessageCount",
|
||||||
FinalStatus = excluded.FinalStatus,
|
"FinalStatus" = excluded."FinalStatus",
|
||||||
ErrorMessage = excluded.ErrorMessage;
|
"ErrorMessage" = excluded."ErrorMessage";
|
||||||
""", cancellationToken);
|
""", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ using LiveRecorder.Application.Models.RecordTasks;
|
|||||||
using LiveRecorder.Application.Services;
|
using LiveRecorder.Application.Services;
|
||||||
using LiveRecorder.Domain.Enums;
|
using LiveRecorder.Domain.Enums;
|
||||||
using LiveRecorder.Infrastructure.Persistence;
|
using LiveRecorder.Infrastructure.Persistence;
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
namespace LiveRecorder.Infrastructure.Services;
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
@@ -637,13 +637,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (exception is DbUpdateException dbUpdateException &&
|
if (exception is DbUpdateException dbUpdateException &&
|
||||||
IsSqliteLockException(dbUpdateException))
|
IsTransientDatabaseException(dbUpdateException))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exception is SqliteException sqliteException &&
|
if (exception is NpgsqlException npgsqlException &&
|
||||||
IsSqliteLockException(sqliteException))
|
npgsqlException.IsTransient)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -691,9 +691,9 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
|||||||
|
|
||||||
private static string BuildExceptionEmailKey(string scope, Exception exception, Guid? liveRoomId = null)
|
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();
|
var root = exception.GetBaseException();
|
||||||
@@ -701,16 +701,21 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
|||||||
return $"{scope}:{liveRoomId?.ToString() ?? "global"}:{root.GetType().FullName}:{message}";
|
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 &&
|
if (exception is PostgresException postgresException &&
|
||||||
(sqliteException.SqliteErrorCode == 13 ||
|
postgresException.SqlState == PostgresErrorCodes.DiskFull)
|
||||||
sqliteException.Message.Contains("database or disk is full", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
{
|
||||||
return true;
|
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(
|
private static async Task UpdateAutoStartDecisionAsync(
|
||||||
@@ -738,21 +743,15 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
|||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return;
|
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);
|
await Task.Delay(TimeSpan.FromMilliseconds(300 * Math.Pow(2, attempt - 1)), cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsSqliteLockException(DbUpdateException exception) =>
|
private static bool IsTransientDatabaseException(DbUpdateException exception) =>
|
||||||
exception.InnerException is SqliteException sqliteException &&
|
exception.InnerException is NpgsqlException npgsqlException && npgsqlException.IsTransient;
|
||||||
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 string? Truncate(string? value, int maxLength)
|
private static string? Truncate(string? value, int maxLength)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
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 ./
|
COPY --from=build /app/publish ./
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
|
|||||||
using LiveRecorder.Infrastructure.Platforms.Huya;
|
using LiveRecorder.Infrastructure.Platforms.Huya;
|
||||||
using LiveRecorder.Infrastructure.Services;
|
using LiveRecorder.Infrastructure.Services;
|
||||||
using LiveRecorder.WebApi.Middleware;
|
using LiveRecorder.WebApi.Middleware;
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var resetRecordingData = args.Contains("--reset-recording-data", StringComparer.OrdinalIgnoreCase);
|
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"];
|
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? ["http://localhost:5173"];
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
@@ -111,16 +111,12 @@ builder.Services.AddHttpClient("bilibili", client =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var sqliteConnectionStringBuilder = new SqliteConnectionStringBuilder(
|
var defaultConnection = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
builder.Configuration.GetConnectionString("DefaultConnection"))
|
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required.");
|
||||||
{
|
|
||||||
Cache = SqliteCacheMode.Shared,
|
|
||||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
|
||||||
DefaultTimeout = 30
|
|
||||||
};
|
|
||||||
|
|
||||||
builder.Services.AddDbContext<LiveRecorderDbContext>(options =>
|
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<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>());
|
||||||
builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>();
|
builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>();
|
||||||
@@ -150,6 +146,7 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
|||||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||||
builder.Services.AddScoped<RecordUploadService>();
|
builder.Services.AddScoped<RecordUploadService>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
|
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<BilibiliWbiSigner>();
|
builder.Services.AddSingleton<BilibiliWbiSigner>();
|
||||||
builder.Services.AddScoped<BilibiliHttpClient>();
|
builder.Services.AddScoped<BilibiliHttpClient>();
|
||||||
@@ -183,11 +180,25 @@ app.UseMiddleware<ApiTokenAuthenticationMiddleware>();
|
|||||||
app.MapGet("/", () => Results.Redirect("/swagger"));
|
app.MapGet("/", () => Results.Redirect("/swagger"));
|
||||||
app.MapControllers();
|
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();
|
using var scope = app.Services.CreateScope();
|
||||||
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
|
var migrator = scope.ServiceProvider.GetRequiredService<SqliteToPostgresMigrationService>();
|
||||||
await initializer.InitializeAsync();
|
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)
|
if (resetRecordingData)
|
||||||
@@ -196,16 +207,15 @@ if (resetRecordingData)
|
|||||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||||
var before = await ReadRecordingDataCountsAsync(dbContext);
|
var before = await ReadRecordingDataCountsAsync(dbContext);
|
||||||
|
|
||||||
await using (var transaction = await dbContext.Database.BeginTransactionAsync())
|
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||||
{
|
await dbContext.SystemLogEntries
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(
|
.Where(item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null)
|
||||||
"DELETE FROM SystemLogEntries WHERE LiveRoomId IS NOT NULL OR RecordSessionId IS NOT NULL OR RecordTaskId IS NOT NULL;");
|
.ExecuteDeleteAsync();
|
||||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordResults;");
|
await dbContext.RecordResults.ExecuteDeleteAsync();
|
||||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordTasks;");
|
await dbContext.RecordTasks.ExecuteDeleteAsync();
|
||||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordSessions;");
|
await dbContext.RecordSessions.ExecuteDeleteAsync();
|
||||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM LiveRooms;");
|
await dbContext.LiveRooms.ExecuteDeleteAsync();
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
}
|
|
||||||
|
|
||||||
var after = await ReadRecordingDataCountsAsync(dbContext);
|
var after = await ReadRecordingDataCountsAsync(dbContext);
|
||||||
Console.WriteLine("Recording data reset complete.");
|
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": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Data Source=live-recorder.db"
|
"DefaultConnection": "Host=localhost;Port=5432;Database=live_recorder;Username=postgres;Password=postgres"
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": [
|
"Origins": [
|
||||||
|
|||||||
Reference in New Issue
Block a user