Files
live_recorder/src/LiveRecorder.WebApi/Program.cs
T

322 lines
13 KiB
C#

using System.Net;
using System.Net.Security;
using System.Security.Authentication;
using LiveRecorder.Application.Abstractions.Auth;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Services;
using LiveRecorder.Infrastructure.Persistence;
using LiveRecorder.Infrastructure.Persistence.Repositories;
using LiveRecorder.Infrastructure.Platforms.Bilibili;
using LiveRecorder.Infrastructure.Platforms.Bilibili.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Common;
using LiveRecorder.Infrastructure.Platforms.Douyu;
using LiveRecorder.Infrastructure.Platforms.Douyin;
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
using LiveRecorder.Infrastructure.Platforms.Huya;
using LiveRecorder.Infrastructure.Platforms.Kuaishou;
using LiveRecorder.Infrastructure.Platforms.Migu;
using LiveRecorder.Infrastructure.Platforms.PandaTV;
using LiveRecorder.Infrastructure.Platforms.TikTok;
using LiveRecorder.Infrastructure.Platforms.Twitch;
using LiveRecorder.Infrastructure.Platforms.Xiaohongshu;
using LiveRecorder.Infrastructure.Platforms.YouTube;
using LiveRecorder.Infrastructure.Services;
using LiveRecorder.WebApi.Middleware;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
var resetRecordingData = args.Contains("--reset-recording-data", StringComparer.OrdinalIgnoreCase);
var migrateSqlitePath = GetOptionValue(args, "--migrate-sqlite");
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? ["http://localhost:5173"];
builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Live Recorder API",
Version = "v1",
Description = "Multi-platform live recording service"
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Input token as: Bearer {token}",
Name = "Authorization",
Type = SecuritySchemeType.ApiKey
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
builder.Services.AddCors(options =>
{
options.AddPolicy("frontend", policy =>
{
policy.WithOrigins(corsOrigins)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddHttpClient("douyin", client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(() => CreateDouyinHttpHandler(useProxy: true));
builder.Services.AddHttpClient("douyin-direct", client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(() => CreateDouyinHttpHandler(useProxy: false));
builder.Services.AddHttpClient("bilibili", client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(static () => new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 8,
ConnectTimeout = TimeSpan.FromSeconds(10),
UseCookies = false,
SslOptions = new SslClientAuthenticationOptions
{
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
}
});
var defaultConnection = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required.");
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(defaultConnection);
if (connectionStringBuilder.Timeout <= 0)
{
connectionStringBuilder.Timeout = 15;
}
if (connectionStringBuilder.CommandTimeout <= 0)
{
connectionStringBuilder.CommandTimeout = 60;
}
if (connectionStringBuilder.KeepAlive <= 0)
{
connectionStringBuilder.KeepAlive = 30;
}
builder.Services.AddDbContext<LiveRecorderDbContext>(options =>
options.UseNpgsql(connectionStringBuilder.ConnectionString, npgsql =>
{
npgsql.MigrationsAssembly(typeof(LiveRecorderDbContext).Assembly.FullName);
npgsql.CommandTimeout(connectionStringBuilder.CommandTimeout);
npgsql.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorCodesToAdd: null);
}));
builder.Services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>());
builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>();
builder.Services.AddScoped<ILiveRoomRepository, LiveRoomRepository>();
builder.Services.AddScoped<IRecordSessionRepository, RecordSessionRepository>();
builder.Services.AddScoped<IRecordTaskRepository, RecordTaskRepository>();
builder.Services.AddScoped<IRecordResultRepository, RecordResultRepository>();
builder.Services.AddScoped<ISystemLogRepository, SystemLogRepository>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserSessionRepository, UserSessionRepository>();
builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>();
builder.Services.AddScoped<ISystemLogService, SystemLogService>();
builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>();
builder.Services.AddScoped<IWebhookNotificationService, WebhookNotificationService>();
builder.Services.AddScoped<IEventScriptService, EventScriptService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<LiveRoomService>();
builder.Services.AddScoped<LiveRoomStatusService>();
builder.Services.AddScoped<LiveRoomRecordingSettingsResolver>();
builder.Services.AddScoped<RecordService>();
builder.Services.AddScoped<RecordSessionService>();
builder.Services.AddScoped<TranscodeTaskService>();
builder.Services.AddScoped<MediaBrowserService>();
builder.Services.AddScoped<SessionAnalyticsService>();
builder.Services.AddScoped<RecoveryService>();
builder.Services.AddScoped<RecordSessionCleanupResolver>();
builder.Services.AddScoped<CleanupOperationCoordinator>();
builder.Services.AddScoped<RetentionCleanupService>();
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
builder.Services.AddScoped<PlatformHttpRequestService>();
builder.Services.AddScoped<RecordUploadService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
builder.Services.AddSingleton<BilibiliWbiSigner>();
builder.Services.AddScoped<BilibiliHttpClient>();
builder.Services.AddScoped<DouyinHttpClient>();
builder.Services.AddSingleton<DouyinXBogusSigner>();
builder.Services.AddSingleton<DouyinLiveWsSignatureSigner>();
builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, HuyaLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, DouyuLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, KuaishouLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, TikTokLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, XiaohongshuLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, YouTubeLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, TwitchLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, PandaTvLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, MiguLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapterFactory, LivePlatformAdapterFactory>();
builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>();
builder.Services.AddScoped<ILiveDanmakuAdapter, BilibiliDanmakuAdapter>();
builder.Services.AddScoped<ILiveDanmakuAdapterFactory, LiveDanmakuAdapterFactory>();
builder.Services.AddSingleton<IFfmpegService, FfmpegService>();
builder.Services.AddSingleton<IStorageGuardService, StorageGuardService>();
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
builder.Services.AddSingleton<LiveRoomPollingBackgroundService>();
builder.Services.AddSingleton<ILiveRoomPollingSignal>(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
builder.Services.AddHostedService<CleanupOperationBackgroundService>();
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors("frontend");
app.UseMiddleware<ApiTokenAuthenticationMiddleware>();
app.MapGet("/", () => Results.Redirect("/swagger"));
app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await initializer.InitializeAsync(seedDefaults: migrateSqlitePath is null, cancellationToken: default);
}
if (!string.IsNullOrWhiteSpace(migrateSqlitePath))
{
using var scope = app.Services.CreateScope();
var migrator = scope.ServiceProvider.GetRequiredService<SqliteToPostgresMigrationService>();
var report = await migrator.MigrateAsync(migrateSqlitePath);
Console.WriteLine($"SQLite migration completed. Source={report.SourcePath}");
foreach (var tableName in report.SourceCounts.Keys.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($"{tableName}: {report.SourceCounts[tableName]} -> {report.TargetCounts[tableName]}");
}
return;
}
if (resetRecordingData)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var before = await ReadRecordingDataCountsAsync(dbContext);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
await dbContext.SystemLogEntries
.Where(item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null)
.ExecuteDeleteAsync();
await dbContext.RecordResults.ExecuteDeleteAsync();
await dbContext.RecordTasks.ExecuteDeleteAsync();
await dbContext.RecordSessions.ExecuteDeleteAsync();
await dbContext.LiveRooms.ExecuteDeleteAsync();
await transaction.CommitAsync();
var after = await ReadRecordingDataCountsAsync(dbContext);
Console.WriteLine("Recording data reset complete.");
foreach (var key in before.Keys)
{
Console.WriteLine($"{key}: {before[key]} -> {after[key]}");
}
return;
}
app.Run();
static async Task<Dictionary<string, long>> ReadRecordingDataCountsAsync(LiveRecorderDbContext dbContext)
{
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
["LiveRooms"] = await dbContext.LiveRooms.LongCountAsync(),
["RecordSessions"] = await dbContext.RecordSessions.LongCountAsync(),
["RecordTasks"] = await dbContext.RecordTasks.LongCountAsync(),
["RecordResults"] = await dbContext.RecordResults.LongCountAsync(),
["SystemLogEntries(Related)"] = await dbContext.SystemLogEntries.LongCountAsync(
item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null)
};
}
static SocketsHttpHandler CreateDouyinHttpHandler(bool useProxy)
{
return new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 8,
ConnectTimeout = TimeSpan.FromSeconds(10),
UseCookies = false,
UseProxy = useProxy,
SslOptions = new SslClientAuthenticationOptions
{
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
}
};
}
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;
}