This commit is contained in:
2026-04-16 15:19:48 +08:00
commit 1c892259a9
127 changed files with 17390 additions and 0 deletions
@@ -0,0 +1,12 @@
using LiveRecorder.Application.Models.Auth;
namespace LiveRecorder.Application.Abstractions.Auth;
public interface IAuthService
{
Task<LoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);
Task LogoutAsync(string token, CancellationToken cancellationToken = default);
Task<AuthenticatedUser?> ValidateTokenAsync(string token, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,24 @@
using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Abstractions.Logging;
public interface ISystemLogService
{
Task WriteAsync(
SystemLogLevel level,
string category,
string message,
string? detail = null,
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<SystemLogDto>> ListAsync(
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
int take = 200,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,19 @@
using LiveRecorder.Domain.Entities;
using LiveRecorder.Application.Models.Settings;
namespace LiveRecorder.Application.Abstractions.Notifications;
public interface IEmailNotificationService
{
Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default);
Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,121 @@
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Abstractions.Persistence;
public interface IAppSettingRepository
{
Task<AppSetting?> GetByKeyAsync(string key, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AppSetting>> ListAsync(CancellationToken cancellationToken = default);
Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default);
void Update(AppSetting setting);
}
public interface ILiveRoomRepository
{
Task<LiveRoom?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<LiveRoom?> GetByPlatformRoomIdAsync(
LivePlatformType platformType,
string roomId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<LiveRoom>> ListAsync(CancellationToken cancellationToken = default);
Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
void Remove(LiveRoom liveRoom);
}
public interface IRecordTaskRepository
{
Task<RecordTask?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RecordTask>> GetByIdsAsync(IReadOnlyCollection<Guid> ids, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RecordTask>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RecordTask>> ListBySessionIdAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default);
void Remove(RecordTask recordTask);
void RemoveRange(IEnumerable<RecordTask> recordTasks);
}
public interface IRecordSessionRepository
{
Task<RecordSession?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
void Remove(RecordSession recordSession);
}
public interface IRecordResultRepository
{
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
void Update(RecordResult recordResult);
void Remove(RecordResult recordResult);
void RemoveRange(IEnumerable<RecordResult> recordResults);
}
public interface ISystemLogRepository
{
Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SystemLogEntry>> ListByRecordTaskIdsAsync(
IReadOnlyCollection<Guid> recordTaskIds,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<SystemLogEntry>> ListByRecordSessionIdsAsync(
IReadOnlyCollection<Guid> recordSessionIds,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<SystemLogEntry>> ListAsync(
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
int take = 200,
CancellationToken cancellationToken = default);
void RemoveRange(IEnumerable<SystemLogEntry> entries);
}
public interface IUserAccountRepository
{
Task<UserAccount?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<UserAccount?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
Task AddAsync(UserAccount userAccount, CancellationToken cancellationToken = default);
}
public interface IUserSessionRepository
{
Task<UserSession?> GetByTokenAsync(string token, CancellationToken cancellationToken = default);
Task AddAsync(UserSession session, CancellationToken cancellationToken = default);
void Update(UserSession session);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,42 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Abstractions.Platforms;
public interface ILiveDanmakuAdapter
{
LivePlatformType PlatformType { get; }
bool CanHandle(LivePlatformType platformType);
Task<ILiveDanmakuConnection> ConnectAsync(DanmakuConnectionContext context, CancellationToken cancellationToken = default);
}
public interface ILiveDanmakuAdapterFactory
{
ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType);
}
public interface ILiveDanmakuConnection : IAsyncDisposable
{
Task StartAsync(Func<DanmakuEvent, Task> onEvent, CancellationToken cancellationToken = default);
}
public sealed record DanmakuConnectionContext(
Guid LiveRoomId,
Guid RecordSessionId,
LivePlatformType PlatformType,
string RoomId,
string? AnchorName,
string? Title,
string? SourceUrl,
int MinPollIntervalMilliseconds = 1000,
int RetryDelayMaxSeconds = 15);
public sealed record DanmakuEvent(
string Type,
string? User,
string? UserId,
string? Content,
DateTimeOffset OccurredAt,
string RawPayload,
IReadOnlyDictionary<string, string>? Extra = null);
@@ -0,0 +1,60 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Abstractions.Platforms;
public interface ILivePlatformAdapter
{
LivePlatformType PlatformType { get; }
bool CanHandle(string input);
Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default);
Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default);
Task<StreamUrlResult> GetStreamUrlAsync(
string roomId,
string? preferredQuality = null,
CancellationToken cancellationToken = default);
}
public interface ILivePlatformAdapterFactory
{
ILivePlatformAdapter GetByPlatform(LivePlatformType platformType);
ILivePlatformAdapter GetByInput(string input);
}
public sealed record ParsedLiveRoom(
LivePlatformType PlatformType,
string RoomId,
string SourceUrl,
string NormalizedUrl);
public sealed record LiveStatusSnapshot(
bool IsLive,
string? Title,
string? AnchorName,
string? CoverUrl,
int? StatusCode,
string? RawStatus);
public sealed record StreamQualityOption(
string QualityKey,
string QualityName,
string Url,
string Protocol,
int Rank);
public sealed record StreamInputHeaders(
string? UserAgent,
string? Referer,
string? Cookie,
IReadOnlyDictionary<string, string>? AdditionalHeaders = null);
public sealed record StreamUrlResult(
string SelectedQuality,
string SelectedProtocol,
string SelectedUrl,
StreamInputHeaders? InputHeaders,
IReadOnlyList<StreamQualityOption> AvailableQualities);
@@ -0,0 +1,32 @@
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Entities;
namespace LiveRecorder.Application.Abstractions.Recording;
public interface IFfmpegService
{
Task StartAsync(
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
CancellationToken cancellationToken = default);
Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
Task<bool> StopAndWaitAsync(
Guid recordSessionId,
bool markAsCompletedOnExit,
TimeSpan timeout,
CancellationToken cancellationToken = default);
Task<bool> KillAndWaitAsync(
Guid recordSessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default);
Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
bool IsRunning(Guid recordSessionId);
}
@@ -0,0 +1,17 @@
namespace LiveRecorder.Application.Abstractions.Recording;
public interface IRecordMediaService
{
Task<RecordPreviewTicketGrant> CreatePreviewTicketAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
Task<RecordMediaFile?> ResolvePreviewAsync(string ticket, CancellationToken cancellationToken = default);
}
public sealed record RecordPreviewTicketGrant(
string Ticket,
DateTimeOffset ExpiresAt);
public sealed record RecordMediaFile(
Guid RecordTaskId,
string FilePath,
string ContentType);
@@ -0,0 +1,10 @@
using LiveRecorder.Application.Models.Settings;
namespace LiveRecorder.Application.Abstractions.Settings;
public interface ISystemSettingsService
{
Task<SystemSettingsDto> GetAsync(CancellationToken cancellationToken = default);
Task<SystemSettingsDto> UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,39 @@
using System.Security.Cryptography;
namespace LiveRecorder.Application.Common;
public static class PasswordHasher
{
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
100_000,
HashAlgorithmName.SHA256,
32);
return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string passwordHash)
{
var parts = passwordHash.Split('.', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
return false;
}
var salt = Convert.FromBase64String(parts[0]);
var storedHash = Convert.FromBase64String(parts[1]);
var computedHash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
100_000,
HashAlgorithmName.SHA256,
storedHash.Length);
return CryptographicOperations.FixedTimeEquals(storedHash, computedHash);
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,30 @@
namespace LiveRecorder.Application.Models.Auth;
public sealed class LoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public sealed class AuthenticatedUser
{
public required Guid UserId { get; init; }
public required string Username { get; init; }
public required string DisplayName { get; init; }
public required string Token { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
public sealed class LoginResponse
{
public required string Token { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required AuthenticatedUser User { get; init; }
}
@@ -0,0 +1,46 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Models.LiveRooms;
public sealed class CreateLiveRoomRequest
{
public string Url { get; set; } = string.Empty;
public LivePlatformType? PlatformOverride { get; set; }
}
public sealed class LiveRoomDto
{
public Guid Id { get; init; }
public required LivePlatformType Platform { get; init; }
public required string PlatformName { get; init; }
public required string SourceUrl { get; init; }
public required string RoomId { get; init; }
public required string NormalizedUrl { get; init; }
public string? Title { get; init; }
public string? AnchorName { get; init; }
public string? CoverUrl { get; init; }
public bool IsEnabled { get; init; }
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
public DateTimeOffset? LastCheckedAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
public sealed class SetLiveRoomEnabledRequest
{
public bool IsEnabled { get; set; }
}
@@ -0,0 +1,24 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Models.Logs;
public sealed class SystemLogDto
{
public Guid Id { get; init; }
public required SystemLogLevel Level { get; init; }
public required string Category { get; init; }
public required string Message { get; init; }
public string? Detail { get; init; }
public Guid? LiveRoomId { get; init; }
public Guid? RecordSessionId { get; init; }
public Guid? RecordTaskId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
@@ -0,0 +1,59 @@
using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Models.RecordTasks;
public sealed class RecordSessionDto
{
public Guid Id { get; init; }
public Guid LiveRoomId { get; init; }
public required string LiveRoomTitle { get; init; }
public required LivePlatformType Platform { get; init; }
public required string RoomId { get; init; }
public required RecordSessionStatus Status { get; init; }
public required string PreferredQuality { get; init; }
public required RecordOutputFormat OutputFormat { get; init; }
public required RecordSaveMode SaveMode { get; init; }
public int ActiveSegmentIndex { get; init; }
public int SegmentCount { get; init; }
public int? RecorderProcessId { get; init; }
public string? ErrorMessage { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? EndedAt { get; init; }
public long TotalFileSizeBytes { get; init; }
public int TotalDanmakuMessageCount { get; init; }
public required IReadOnlyList<RecordTaskDto> Tasks { get; init; }
}
public sealed class RecordSessionDetailDto
{
public required RecordSessionDto Session { get; init; }
public required IReadOnlyList<SystemLogDto> Logs { get; init; }
}
public sealed class DeleteRecordSessionsRequest
{
public IReadOnlyList<Guid> SessionIds { get; set; } = [];
public bool DeleteFiles { get; set; }
}
@@ -0,0 +1,109 @@
using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Models.RecordTasks;
public sealed class StartRecordTaskRequest
{
public Guid LiveRoomId { get; set; }
public string? PreferredQuality { get; set; }
public RecordOutputFormat? OutputFormat { get; set; }
}
public sealed class RecordResultDto
{
public Guid Id { get; init; }
public required string FilePath { get; init; }
public long? FileSizeBytes { get; init; }
public double? DurationSeconds { get; init; }
public string? DanmakuFilePath { get; init; }
public int DanmakuMessageCount { get; init; }
public required RecordTaskStatus FinalStatus { get; init; }
public string? ErrorMessage { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
public sealed class RecordTaskDto
{
public Guid Id { get; init; }
public Guid LiveRoomId { get; init; }
public Guid RecordSessionId { get; init; }
public int SegmentIndex { get; init; }
public required string LiveRoomTitle { get; init; }
public required LivePlatformType Platform { get; init; }
public required string RoomId { get; init; }
public required RecordTaskStatus Status { get; init; }
public required string PreferredQuality { get; init; }
public required RecordOutputFormat OutputFormat { get; init; }
public string? StreamUrl { get; init; }
public string? OutputFilePath { get; init; }
public int? RecorderProcessId { get; init; }
public string? ErrorMessage { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? EndedAt { get; init; }
public double? DurationSeconds { get; init; }
}
public sealed class RecordTaskDetailDto
{
public required RecordTaskDto Task { get; init; }
public RecordResultDto? Result { get; init; }
public required IReadOnlyList<SystemLogDto> Logs { get; init; }
}
public sealed class DeleteCompletedRecordTasksRequest
{
public IReadOnlyList<Guid> TaskIds { get; set; } = [];
public bool DeleteFiles { get; set; }
}
public sealed class DeleteCompletedRecordTasksResultDto
{
public required IReadOnlyList<Guid> DeletedTaskIds { get; init; }
public required IReadOnlyList<string> DeletedFilePaths { get; init; }
public required IReadOnlyList<string> DeletedDanmakuPaths { get; init; }
public required IReadOnlyList<Guid> DeletedSessionIds { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
}
public sealed class RecordPreviewTicketDto
{
public required string Url { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
@@ -0,0 +1,267 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Models.Settings;
public sealed class SystemSettingsDto
{
public string FfmpegPath { get; set; } = "ffmpeg";
public string OutputRoot { get; set; } = "records";
public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}";
public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}";
public string DefaultQuality { get; set; } = "origin";
public RecordOutputFormat DefaultOutputFormat { get; set; } = RecordOutputFormat.Mp4;
public RecordSaveMode SaveMode { get; set; } = RecordSaveMode.SingleFile;
public RecordingTemplateType RecordingTemplate { get; set; } = RecordingTemplateType.StreamCopy;
public int SegmentDurationMinutes { get; set; } = 30;
public bool EnableAutoReconnect { get; set; } = true;
public int ReconnectDelayMaxSeconds { get; set; } = 5;
public int ReadWriteTimeoutMilliseconds { get; set; } = 15000000;
public bool EnableDanmakuRecording { get; set; } = true;
public bool DanmakuIncludeNonChatEvents { get; set; } = true;
public int DanmakuMinPollIntervalMilliseconds { get; set; } = 1000;
public int DanmakuRetryDelayMaxSeconds { get; set; } = 15;
public bool EnableBackgroundPolling { get; set; } = true;
public bool AutoStartRecordingOnLive { get; set; } = true;
public int PollingIntervalSeconds { get; set; } = 60;
public bool EnableEmailNotification { get; set; } = false;
public string EmailSmtpHost { get; set; } = string.Empty;
public int EmailSmtpPort { get; set; } = 587;
public bool EmailUseSsl { get; set; } = true;
public string EmailUsername { get; set; } = string.Empty;
public string EmailPassword { get; set; } = string.Empty;
public string EmailFromAddress { get; set; } = string.Empty;
public string EmailFromDisplayName { get; set; } = "Live Recorder";
public string EmailToAddresses { get; set; } = string.Empty;
public bool NotifyOnLiveStarted { get; set; } = true;
public bool NotifyOnException { get; set; } = true;
public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})";
public string EmailLiveStartedBodyTemplateHtml { get; set; } = """
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
<p>The monitored live room is now online.</p>
<ul>
<li><strong>Platform:</strong> {{platform}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
</div>
""";
public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}";
public string EmailExceptionBodyTemplateHtml { get; set; } = """
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
<p>{{summary}}</p>
<ul>
<li><strong>Source:</strong> {{source}}</li>
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>
""";
public string DouyinUserAgent { get; set; } =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
public string DouyinReferer { get; set; } = "https://live.douyin.com/";
public string DouyinCookie { get; set; } = string.Empty;
}
public sealed class UpdateSystemSettingsRequest
{
public string FfmpegPath { get; set; } = "ffmpeg";
public string OutputRoot { get; set; } = "records";
public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}";
public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}";
public string DefaultQuality { get; set; } = "origin";
public RecordOutputFormat DefaultOutputFormat { get; set; } = RecordOutputFormat.Mp4;
public RecordSaveMode SaveMode { get; set; } = RecordSaveMode.SingleFile;
public RecordingTemplateType RecordingTemplate { get; set; } = RecordingTemplateType.StreamCopy;
public int SegmentDurationMinutes { get; set; } = 30;
public bool EnableAutoReconnect { get; set; } = true;
public int ReconnectDelayMaxSeconds { get; set; } = 5;
public int ReadWriteTimeoutMilliseconds { get; set; } = 15000000;
public bool EnableDanmakuRecording { get; set; } = true;
public bool DanmakuIncludeNonChatEvents { get; set; } = true;
public int DanmakuMinPollIntervalMilliseconds { get; set; } = 1000;
public int DanmakuRetryDelayMaxSeconds { get; set; } = 15;
public bool EnableBackgroundPolling { get; set; } = true;
public bool AutoStartRecordingOnLive { get; set; } = true;
public int PollingIntervalSeconds { get; set; } = 60;
public bool EnableEmailNotification { get; set; } = false;
public string EmailSmtpHost { get; set; } = string.Empty;
public int EmailSmtpPort { get; set; } = 587;
public bool EmailUseSsl { get; set; } = true;
public string EmailUsername { get; set; } = string.Empty;
public string EmailPassword { get; set; } = string.Empty;
public string EmailFromAddress { get; set; } = string.Empty;
public string EmailFromDisplayName { get; set; } = "Live Recorder";
public string EmailToAddresses { get; set; } = string.Empty;
public bool NotifyOnLiveStarted { get; set; } = true;
public bool NotifyOnException { get; set; } = true;
public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})";
public string EmailLiveStartedBodyTemplateHtml { get; set; } = """
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
<p>The monitored live room is now online.</p>
<ul>
<li><strong>Platform:</strong> {{platform}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
</div>
""";
public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}";
public string EmailExceptionBodyTemplateHtml { get; set; } = """
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
<p>{{summary}}</p>
<ul>
<li><strong>Source:</strong> {{source}}</li>
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>
""";
public string DouyinUserAgent { get; set; } =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
public string DouyinReferer { get; set; } = "https://live.douyin.com/";
public string DouyinCookie { get; set; } = string.Empty;
}
public sealed class SendTestEmailRequest
{
public string EmailSmtpHost { get; set; } = string.Empty;
public int EmailSmtpPort { get; set; } = 587;
public bool EmailUseSsl { get; set; } = true;
public string EmailUsername { get; set; } = string.Empty;
public string EmailPassword { get; set; } = string.Empty;
public string EmailFromAddress { get; set; } = string.Empty;
public string EmailFromDisplayName { get; set; } = "Live Recorder";
public string EmailToAddresses { get; set; } = string.Empty;
public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})";
public string EmailLiveStartedBodyTemplateHtml { get; set; } = """
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
<p>The monitored live room is now online.</p>
<ul>
<li><strong>Platform:</strong> {{platform}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
</div>
""";
public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}";
public string EmailExceptionBodyTemplateHtml { get; set; } = """
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
<p>{{summary}}</p>
<ul>
<li><strong>Source:</strong> {{source}}</li>
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>
""";
}
@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using LiveRecorder.Application.Abstractions.Auth;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Common;
using LiveRecorder.Application.Models.Auth;
using LiveRecorder.Domain.Entities;
namespace LiveRecorder.Application.Services;
public sealed class AuthService : IAuthService
{
private readonly IUserAccountRepository _userAccountRepository;
private readonly IUserSessionRepository _userSessionRepository;
private readonly IUnitOfWork _unitOfWork;
public AuthService(
IUserAccountRepository userAccountRepository,
IUserSessionRepository userSessionRepository,
IUnitOfWork unitOfWork)
{
_userAccountRepository = userAccountRepository;
_userSessionRepository = userSessionRepository;
_unitOfWork = unitOfWork;
}
public async Task<LoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var username = request.Username.Trim();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
{
throw new InvalidOperationException("用户名和密码不能为空。");
}
var user = await _userAccountRepository.GetByUsernameAsync(username, cancellationToken)
?? throw new InvalidOperationException("用户名或密码错误。");
if (!user.IsActive || !PasswordHasher.Verify(request.Password, user.PasswordHash))
{
throw new InvalidOperationException("用户名或密码错误。");
}
var now = DateTimeOffset.UtcNow;
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var expiresAt = now.AddHours(12);
var session = new UserSession(user.Id, token, expiresAt, now);
await _userSessionRepository.AddAsync(session, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var authenticatedUser = new AuthenticatedUser
{
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Token = token,
ExpiresAt = expiresAt
};
return new LoginResponse
{
Token = token,
ExpiresAt = expiresAt,
User = authenticatedUser
};
}
public async Task LogoutAsync(string token, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(token))
{
return;
}
var session = await _userSessionRepository.GetByTokenAsync(token, cancellationToken);
if (session is null)
{
return;
}
session.Revoke(DateTimeOffset.UtcNow);
_userSessionRepository.Update(session);
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
public async Task<AuthenticatedUser?> ValidateTokenAsync(string token, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
var session = await _userSessionRepository.GetByTokenAsync(token, cancellationToken);
if (session is null || !session.IsValid(DateTimeOffset.UtcNow))
{
return null;
}
var user = await _userAccountRepository.GetByIdAsync(session.UserAccountId, cancellationToken);
if (user is null || !user.IsActive)
{
return null;
}
return new AuthenticatedUser
{
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Token = session.Token,
ExpiresAt = session.ExpiresAt
};
}
}
@@ -0,0 +1,24 @@
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class LiveDanmakuAdapterFactory : ILiveDanmakuAdapterFactory
{
private readonly IReadOnlyDictionary<LivePlatformType, ILiveDanmakuAdapter> _adapterByPlatform;
public LiveDanmakuAdapterFactory(IEnumerable<ILiveDanmakuAdapter> adapters)
{
_adapterByPlatform = adapters.ToDictionary(static item => item.PlatformType);
}
public ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType)
{
if (!_adapterByPlatform.TryGetValue(platformType, out var adapter))
{
throw new NotSupportedException($"No danmaku adapter is registered for platform {platformType}.");
}
return adapter;
}
}
@@ -0,0 +1,37 @@
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class LivePlatformAdapterFactory : ILivePlatformAdapterFactory
{
private readonly IReadOnlyDictionary<LivePlatformType, ILivePlatformAdapter> _adapterByPlatform;
private readonly IReadOnlyList<ILivePlatformAdapter> _adapters;
public LivePlatformAdapterFactory(IEnumerable<ILivePlatformAdapter> adapters)
{
_adapters = adapters.ToList();
_adapterByPlatform = _adapters.ToDictionary(static item => item.PlatformType);
}
public ILivePlatformAdapter GetByPlatform(LivePlatformType platformType)
{
if (!_adapterByPlatform.TryGetValue(platformType, out var adapter))
{
throw new NotSupportedException($"未找到平台适配器: {platformType}。");
}
return adapter;
}
public ILivePlatformAdapter GetByInput(string input)
{
var adapter = _adapters.FirstOrDefault(item => item.CanHandle(input));
if (adapter is null)
{
throw new NotSupportedException("无法根据输入识别直播平台,请指定平台后重试。");
}
return adapter;
}
}
@@ -0,0 +1,175 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Models.LiveRooms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class LiveRoomService
{
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
private readonly LiveRoomStatusService _liveRoomStatusService;
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
private readonly IUnitOfWork _unitOfWork;
private readonly ISystemLogService _systemLogService;
public LiveRoomService(
ILiveRoomRepository liveRoomRepository,
ILivePlatformAdapterFactory livePlatformAdapterFactory,
LiveRoomStatusService liveRoomStatusService,
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
IUnitOfWork unitOfWork,
ISystemLogService systemLogService)
{
_liveRoomRepository = liveRoomRepository;
_livePlatformAdapterFactory = livePlatformAdapterFactory;
_liveRoomStatusService = liveRoomStatusService;
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
_unitOfWork = unitOfWork;
_systemLogService = systemLogService;
}
public async Task<IReadOnlyList<LiveRoomDto>> ListAsync(CancellationToken cancellationToken = default)
{
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
return rooms
.OrderByDescending(static item => item.UpdatedAt)
.Select(Map)
.ToList();
}
public async Task<LiveRoomDto?> GetAsync(Guid id, CancellationToken cancellationToken = default)
{
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken);
return room is null ? null : Map(room);
}
public async Task<LiveRoomDto> CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var input = request.Url.Trim();
if (string.IsNullOrWhiteSpace(input))
{
throw new InvalidOperationException("Live room URL is required.");
}
var adapter = request.PlatformOverride.HasValue && request.PlatformOverride.Value != LivePlatformType.Unknown
? _livePlatformAdapterFactory.GetByPlatform(request.PlatformOverride.Value)
: _livePlatformAdapterFactory.GetByInput(input);
var parsedRoom = await adapter.ParseRoomAsync(input, cancellationToken);
var liveStatus = await adapter.GetLiveStatusAsync(parsedRoom.RoomId, cancellationToken);
var now = DateTimeOffset.UtcNow;
var liveRoom = await _liveRoomRepository.GetByPlatformRoomIdAsync(parsedRoom.PlatformType, parsedRoom.RoomId, cancellationToken);
if (liveRoom is null)
{
liveRoom = new LiveRoom(parsedRoom.PlatformType, parsedRoom.SourceUrl, parsedRoom.RoomId, parsedRoom.NormalizedUrl, now);
await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
await _liveRoomRepository.AddAsync(liveRoom, cancellationToken);
}
else
{
liveRoom.UpdateSource(parsedRoom.SourceUrl, parsedRoom.NormalizedUrl, now);
liveRoom.UpdateRoomId(parsedRoom.RoomId, now);
await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"LiveRoom",
$"Live room resolved: {liveRoom.RoomId} ({liveRoom.Platform}).",
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
return Map(liveRoom);
}
public async Task<LiveRoomDto> RefreshStatusAsync(Guid id, CancellationToken cancellationToken = default)
{
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Live room was not found.");
var adapter = _livePlatformAdapterFactory.GetByPlatform(room.Platform);
var liveStatus = await adapter.GetLiveStatusAsync(room.RoomId, cancellationToken);
var now = DateTimeOffset.UtcNow;
await _liveRoomStatusService.ApplySnapshotAsync(room, liveStatus, now, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"LiveRoom",
$"Live status refreshed for room {room.RoomId}.",
detail: $"status={liveStatus.StatusCode}, rawStatus={liveStatus.RawStatus}",
liveRoomId: room.Id,
cancellationToken: cancellationToken);
return Map(room);
}
public async Task<LiveRoomDto> SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
{
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Live room was not found.");
room.SetEnabled(isEnabled, DateTimeOffset.UtcNow);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"LiveRoom",
isEnabled
? $"Live room {room.RoomId} was enabled."
: $"Live room {room.RoomId} was disabled.",
liveRoomId: room.Id,
cancellationToken: cancellationToken);
return Map(room);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Live room was not found.");
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(
liveRoomId: room.Id,
treatMatchingLiveRoomAsMissing: true,
cancellationToken: cancellationToken);
_liveRoomRepository.Remove(room);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"LiveRoom",
$"Live room {room.RoomId} was deleted.",
liveRoomId: room.Id,
cancellationToken: cancellationToken);
}
private static LiveRoomDto Map(LiveRoom room) => new()
{
Id = room.Id,
Platform = room.Platform,
PlatformName = room.Platform.ToString(),
SourceUrl = room.SourceUrl,
RoomId = room.RoomId,
NormalizedUrl = room.NormalizedUrl,
Title = room.Title,
AnchorName = room.AnchorName,
CoverUrl = room.CoverUrl,
IsEnabled = room.IsEnabled,
AvailabilityStatus = room.AvailabilityStatus,
LastCheckedAt = room.LastCheckedAt,
CreatedAt = room.CreatedAt,
UpdatedAt = room.UpdatedAt
};
}
@@ -0,0 +1,39 @@
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class LiveRoomStatusService
{
private readonly IEmailNotificationService _emailNotificationService;
public LiveRoomStatusService(IEmailNotificationService emailNotificationService)
{
_emailNotificationService = emailNotificationService;
}
public async Task ApplySnapshotAsync(
LiveRoom liveRoom,
LiveStatusSnapshot liveStatus,
DateTimeOffset observedAt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(liveRoom);
ArgumentNullException.ThrowIfNull(liveStatus);
liveRoom.UpdateMetadata(liveStatus.Title, liveStatus.AnchorName, liveStatus.CoverUrl, observedAt);
liveRoom.UpdateAvailability(
liveStatus.IsLive ? LiveRoomAvailabilityStatus.Live : LiveRoomAvailabilityStatus.Offline,
observedAt);
if (!liveStatus.IsLive || liveRoom.HasSentLiveNotificationForCurrentSession)
{
return;
}
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
liveRoom.MarkLiveNotificationSent(observedAt);
}
}
@@ -0,0 +1,106 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
internal static class RecordModelMapper
{
public static RecordTaskDto MapTask(RecordTask recordTask) => new()
{
Id = recordTask.Id,
LiveRoomId = recordTask.LiveRoomId,
RecordSessionId = recordTask.RecordSessionId,
SegmentIndex = recordTask.SegmentIndex,
LiveRoomTitle = recordTask.LiveRoom?.Title ?? recordTask.LiveRoom?.AnchorName ?? recordTask.LiveRoom?.RoomId ?? "Unknown Room",
Platform = recordTask.LiveRoom?.Platform ?? LivePlatformType.Unknown,
RoomId = recordTask.LiveRoom?.RoomId ?? string.Empty,
Status = recordTask.Status,
PreferredQuality = recordTask.PreferredQuality,
OutputFormat = recordTask.OutputFormat,
StreamUrl = recordTask.StreamUrl,
OutputFilePath = recordTask.OutputFilePath,
RecorderProcessId = recordTask.RecorderProcessId,
ErrorMessage = recordTask.ErrorMessage,
CreatedAt = recordTask.CreatedAt,
StartedAt = recordTask.StartedAt,
EndedAt = recordTask.EndedAt,
DurationSeconds = recordTask.DurationSeconds
};
public static RecordResultDto MapResult(RecordResult recordResult) => new()
{
Id = recordResult.Id,
FilePath = recordResult.FilePath,
FileSizeBytes = recordResult.FileSizeBytes,
DurationSeconds = recordResult.DurationSeconds,
DanmakuFilePath = recordResult.DanmakuFilePath,
DanmakuMessageCount = recordResult.DanmakuMessageCount,
FinalStatus = recordResult.FinalStatus,
ErrorMessage = recordResult.ErrorMessage,
CreatedAt = recordResult.CreatedAt
};
public static RecordSessionDto MapSession(RecordSession recordSession)
{
var orderedTasks = recordSession.RecordTasks
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.ToList();
var totalFileSizeBytes = orderedTasks
.Select(static item => item.Result?.FileSizeBytes ?? 0L)
.Sum();
var totalDanmakuMessageCount = orderedTasks
.Select(static item => item.Result?.DanmakuMessageCount ?? 0)
.Sum();
return new RecordSessionDto
{
Id = recordSession.Id,
LiveRoomId = recordSession.LiveRoomId,
LiveRoomTitle = recordSession.LiveRoom?.Title ?? recordSession.LiveRoom?.AnchorName ?? recordSession.LiveRoom?.RoomId ?? "Unknown Room",
Platform = recordSession.LiveRoom?.Platform ?? LivePlatformType.Unknown,
RoomId = recordSession.LiveRoom?.RoomId ?? string.Empty,
Status = recordSession.Status,
PreferredQuality = recordSession.PreferredQuality,
OutputFormat = recordSession.OutputFormat,
SaveMode = recordSession.SaveMode,
ActiveSegmentIndex = recordSession.ActiveSegmentIndex,
SegmentCount = Math.Max(recordSession.SegmentCount, orderedTasks.Count),
RecorderProcessId = recordSession.RecorderProcessId,
ErrorMessage = recordSession.ErrorMessage,
CreatedAt = recordSession.CreatedAt,
StartedAt = recordSession.StartedAt,
EndedAt = recordSession.EndedAt,
TotalFileSizeBytes = totalFileSizeBytes,
TotalDanmakuMessageCount = totalDanmakuMessageCount,
Tasks = orderedTasks.Select(item => MapTaskWithFallback(item, recordSession)).ToList()
};
}
private static RecordTaskDto MapTaskWithFallback(RecordTask recordTask, RecordSession recordSession)
{
var liveRoom = recordTask.LiveRoom ?? recordSession.LiveRoom;
return new RecordTaskDto
{
Id = recordTask.Id,
LiveRoomId = recordTask.LiveRoomId,
RecordSessionId = recordTask.RecordSessionId,
SegmentIndex = recordTask.SegmentIndex,
LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room",
Platform = liveRoom?.Platform ?? LivePlatformType.Unknown,
RoomId = liveRoom?.RoomId ?? string.Empty,
Status = recordTask.Status,
PreferredQuality = recordTask.PreferredQuality,
OutputFormat = recordTask.OutputFormat,
StreamUrl = recordTask.StreamUrl,
OutputFilePath = recordTask.OutputFilePath,
RecorderProcessId = recordTask.RecorderProcessId,
ErrorMessage = recordTask.ErrorMessage,
CreatedAt = recordTask.CreatedAt,
StartedAt = recordTask.StartedAt,
EndedAt = recordTask.EndedAt,
DurationSeconds = recordTask.DurationSeconds
};
}
}
@@ -0,0 +1,670 @@
using System.Text.RegularExpressions;
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.Settings;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class RecordService
{
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordResultRepository _recordResultRepository;
private readonly ISystemLogRepository _systemLogRepository;
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
private readonly IRecordMediaService _recordMediaService;
private readonly IFfmpegService _ffmpegService;
private readonly ISystemSettingsService _systemSettingsService;
private readonly ISystemLogService _systemLogService;
private readonly IEmailNotificationService _emailNotificationService;
private readonly LiveRoomStatusService _liveRoomStatusService;
private readonly IUnitOfWork _unitOfWork;
public RecordService(
ILiveRoomRepository liveRoomRepository,
IRecordSessionRepository recordSessionRepository,
IRecordTaskRepository recordTaskRepository,
IRecordResultRepository recordResultRepository,
ISystemLogRepository systemLogRepository,
ILivePlatformAdapterFactory livePlatformAdapterFactory,
IRecordMediaService recordMediaService,
IFfmpegService ffmpegService,
ISystemSettingsService systemSettingsService,
ISystemLogService systemLogService,
IEmailNotificationService emailNotificationService,
LiveRoomStatusService liveRoomStatusService,
IUnitOfWork unitOfWork)
{
_liveRoomRepository = liveRoomRepository;
_recordSessionRepository = recordSessionRepository;
_recordTaskRepository = recordTaskRepository;
_recordResultRepository = recordResultRepository;
_systemLogRepository = systemLogRepository;
_livePlatformAdapterFactory = livePlatformAdapterFactory;
_recordMediaService = recordMediaService;
_ffmpegService = ffmpegService;
_systemSettingsService = systemSettingsService;
_systemLogService = systemLogService;
_emailNotificationService = emailNotificationService;
_liveRoomStatusService = liveRoomStatusService;
_unitOfWork = unitOfWork;
}
public async Task<IReadOnlyList<RecordTaskDto>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default)
{
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
var tasks = await _recordTaskRepository.ListAsync(liveRoomId, cancellationToken);
return tasks
.OrderByDescending(static item => item.CreatedAt)
.Select(RecordModelMapper.MapTask)
.ToList();
}
public async Task<RecordTaskDetailDto?> GetDetailAsync(Guid id, CancellationToken cancellationToken = default)
{
var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken);
if (recordTask is null)
{
return null;
}
if (recordTask.RecordSessionId != Guid.Empty)
{
await _ffmpegService.TryReconcileInactiveSessionAsync(recordTask.RecordSessionId, cancellationToken);
recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken);
if (recordTask is null)
{
return null;
}
}
var recordResult = await _recordResultRepository.GetByTaskIdAsync(id, cancellationToken);
var logs = await _systemLogService.ListAsync(
recordSessionId: recordTask.RecordSessionId,
recordTaskId: id,
take: 300,
cancellationToken: cancellationToken);
return new RecordTaskDetailDto
{
Task = RecordModelMapper.MapTask(recordTask),
Result = recordResult is null ? null : RecordModelMapper.MapResult(recordResult),
Logs = logs
};
}
public async Task<RecordTaskDto> StartAsync(StartRecordTaskRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var liveRoom = await _liveRoomRepository.GetByIdAsync(request.LiveRoomId, cancellationToken)
?? throw new KeyNotFoundException("Live room was not found.");
if (!liveRoom.IsEnabled)
{
throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording.");
}
var activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken);
if (activeSession is not null)
{
throw new InvalidOperationException("An active recording session already exists for the live room.");
}
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var adapter = _livePlatformAdapterFactory.GetByPlatform(liveRoom.Platform);
var preferredQuality = string.IsNullOrWhiteSpace(request.PreferredQuality) ? settings.DefaultQuality : request.PreferredQuality.Trim();
var outputFormat = request.OutputFormat ?? settings.DefaultOutputFormat;
var saveMode = settings.SaveMode;
var now = DateTimeOffset.UtcNow;
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var initialTask = new RecordTask(liveRoom.Id, recordSession.Id, 1, preferredQuality, outputFormat, now);
await _recordTaskRepository.AddAsync(initialTask, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
try
{
var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken);
await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
if (!liveStatus.IsLive)
{
initialTask.MarkFailed("The live room is currently offline.", now);
recordSession.MarkFailed("The live room is currently offline.", now);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"RecordSession",
"Recording start skipped because the live room is offline.",
liveRoomId: liveRoom.Id,
recordSessionId: recordSession.Id,
recordTaskId: initialTask.Id,
cancellationToken: cancellationToken);
return RecordModelMapper.MapTask(initialTask);
}
var streamResult = await adapter.GetStreamUrlAsync(liveRoom.RoomId, preferredQuality, cancellationToken);
var outputPattern = BuildOutputPathPattern(
settings.OutputRoot,
settings.OutputDirectoryTemplate,
settings.OutputFileNameTemplate,
liveRoom.Platform,
liveRoom.RoomId,
liveRoom.AnchorName,
liveRoom.Title,
outputFormat,
saveMode,
now);
var initialOutputPath = ResolveSegmentOutputPath(outputPattern, outputFormat, saveMode, 1);
recordSession.MarkStarting(streamResult.SelectedUrl, outputPattern, now);
recordSession.ActivateSegment(1, now);
initialTask.MarkStarting(streamResult.SelectedUrl, initialOutputPath, now);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _ffmpegService.StartAsync(recordSession, initialTask, streamResult, cancellationToken);
recordSession.MarkRunning(DateTimeOffset.UtcNow);
initialTask.MarkRunning(DateTimeOffset.UtcNow);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"RecordSession",
$"Recording session started with quality={streamResult.SelectedQuality}, protocol={streamResult.SelectedProtocol}.",
detail: outputPattern,
liveRoomId: liveRoom.Id,
recordSessionId: recordSession.Id,
recordTaskId: initialTask.Id,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
initialTask.MarkFailed(ex.Message, DateTimeOffset.UtcNow);
recordSession.MarkFailed(ex.Message, DateTimeOffset.UtcNow);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Error,
"RecordSession",
"Recording session startup failed.",
ex.ToString(),
liveRoom.Id,
recordSession.Id,
initialTask.Id,
cancellationToken);
await _emailNotificationService.SendExceptionAsync(
"RecordSession",
"Recording session startup failed.",
ex.ToString(),
liveRoom,
initialTask,
cancellationToken);
}
return RecordModelMapper.MapTask(initialTask);
}
public Task<DeleteCompletedRecordTasksResultDto> DeleteCompletedAsync(
DeleteCompletedRecordTasksRequest request,
CancellationToken cancellationToken = default) =>
DeleteTasksAsync(request, cancellationToken);
public async Task<DeleteCompletedRecordTasksResultDto> DeleteTasksAsync(
DeleteCompletedRecordTasksRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var taskIds = request.TaskIds
.Where(static item => item != Guid.Empty)
.Distinct()
.ToArray();
if (taskIds.Length == 0)
{
return CreateEmptyDeleteResult();
}
var recordTasks = await _recordTaskRepository.GetByIdsAsync(taskIds, cancellationToken);
var tasksById = recordTasks.ToDictionary(static item => item.Id);
var warnings = new List<string>();
var deletedTaskIds = new List<Guid>();
var deletedFilePaths = new List<string>();
var deletedDanmakuPaths = new List<string>();
var affectedSessionIds = new HashSet<Guid>();
foreach (var taskId in taskIds)
{
if (!tasksById.TryGetValue(taskId, out var recordTask))
{
warnings.Add($"Task {taskId} was not found.");
continue;
}
if (IsActiveTaskStatus(recordTask.Status))
{
warnings.Add($"Task {recordTask.Id} is active and cannot be deleted.");
continue;
}
if (request.DeleteFiles)
{
TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths);
}
if (recordTask.Result is not null)
{
_recordResultRepository.Remove(recordTask.Result);
}
var relatedLogs = await _systemLogRepository.ListByRecordTaskIdsAsync([recordTask.Id], cancellationToken);
if (relatedLogs.Count > 0)
{
_systemLogRepository.RemoveRange(relatedLogs);
}
_recordTaskRepository.Remove(recordTask);
deletedTaskIds.Add(recordTask.Id);
affectedSessionIds.Add(recordTask.RecordSessionId);
}
var deletedSessionIds = new List<Guid>();
if (deletedTaskIds.Count > 0)
{
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
if (affectedSessionIds.Count > 0)
{
var sessions = await _recordSessionRepository.ListAsync(cancellationToken: cancellationToken);
foreach (var session in sessions.Where(item => affectedSessionIds.Contains(item.Id)))
{
var remainingTasks = await _recordTaskRepository.ListBySessionIdAsync(session.Id, cancellationToken);
if (remainingTasks.Count != 0 || IsActiveSessionStatus(session.Status))
{
continue;
}
var sessionLogs = await _systemLogRepository.ListByRecordSessionIdsAsync([session.Id], cancellationToken);
if (sessionLogs.Count > 0)
{
_systemLogRepository.RemoveRange(sessionLogs);
}
_recordSessionRepository.Remove(session);
deletedSessionIds.Add(session.Id);
}
}
if (deletedSessionIds.Count > 0)
{
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
if (deletedTaskIds.Count > 0 || deletedSessionIds.Count > 0)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"RecordTask",
$"Deleted {deletedTaskIds.Count} recording task(s).",
detail: request.DeleteFiles
? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; sessions={deletedSessionIds.Count}"
: $"video-files=0; danmaku-files=0; sessions={deletedSessionIds.Count}",
cancellationToken: cancellationToken);
}
return new DeleteCompletedRecordTasksResultDto
{
DeletedTaskIds = deletedTaskIds,
DeletedFilePaths = deletedFilePaths,
DeletedDanmakuPaths = deletedDanmakuPaths,
DeletedSessionIds = deletedSessionIds,
Warnings = warnings
};
}
public async Task<RecordPreviewTicketDto> CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default)
{
var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken);
return new RecordPreviewTicketDto
{
Url = $"{mediaBaseUrl.TrimEnd('/')}/{previewTicket.Ticket}",
ExpiresAt = previewTicket.ExpiresAt
};
}
public async Task<RecordTaskDto> StopAsync(Guid id, CancellationToken cancellationToken = default)
{
var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Recording task was not found.");
if (!IsActiveTaskStatus(recordTask.Status))
{
throw new InvalidOperationException("The recording task is not running.");
}
var session = await _recordSessionRepository.GetByIdAsync(recordTask.RecordSessionId, cancellationToken)
?? throw new KeyNotFoundException("Recording session was not found.");
recordTask.MarkStopping(DateTimeOffset.UtcNow);
session.MarkStopping(DateTimeOffset.UtcNow);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _ffmpegService.StopAsync(session.Id, cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"RecordSession",
"Stop signal sent to the active recording session.",
liveRoomId: recordTask.LiveRoomId,
recordSessionId: session.Id,
recordTaskId: recordTask.Id,
cancellationToken: cancellationToken);
return RecordModelMapper.MapTask(recordTask);
}
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
{
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
var activeSessionIds = sessions
.Where(item => IsActiveSessionStatus(item.Status))
.Select(item => item.Id)
.Distinct()
.ToArray();
foreach (var activeSessionId in activeSessionIds)
{
await _ffmpegService.TryReconcileInactiveSessionAsync(activeSessionId, cancellationToken);
}
}
internal static DeleteCompletedRecordTasksResultDto CreateEmptyDeleteResult() => new()
{
DeletedTaskIds = [],
DeletedFilePaths = [],
DeletedDanmakuPaths = [],
DeletedSessionIds = [],
Warnings = []
};
private static bool IsActiveTaskStatus(RecordTaskStatus status) =>
status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping;
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
private static string BuildOutputPathPattern(
string outputRoot,
string outputDirectoryTemplate,
string outputFileNameTemplate,
LivePlatformType platform,
string roomId,
string? anchorName,
string? title,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
DateTimeOffset now)
{
var safeRoomId = SanitizeFileName(roomId, "room");
var effectiveFileNameTemplate = EnsureSegmentSuffixTemplate(outputFileNameTemplate, saveMode);
var baseFileStem = BuildFileNameStem(
effectiveFileNameTemplate,
platform,
safeRoomId,
anchorName,
title,
now,
segmentSuffix: string.Empty);
var directoryPath = BuildDirectoryPath(
outputDirectoryTemplate,
platform,
safeRoomId,
anchorName,
title,
now,
baseFileStem);
var fileNameStem = BuildFileNameStem(
effectiveFileNameTemplate,
platform,
safeRoomId,
anchorName,
title,
now,
saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty);
var folder = Path.Combine(outputRoot, directoryPath);
var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4";
return Path.Combine(folder, $"{fileNameStem}.{extension}");
}
internal static string ResolveSegmentOutputPath(
string outputPathPattern,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
int segmentIndex)
{
if (saveMode != RecordSaveMode.Segmented)
{
return outputPathPattern;
}
return outputPathPattern.Replace("%05d", $"{Math.Max(1, segmentIndex):D5}", StringComparison.OrdinalIgnoreCase);
}
private static string BuildDirectoryPath(
string template,
LivePlatformType platform,
string roomId,
string? anchorName,
string? title,
DateTimeOffset now,
string fileStem)
{
var raw = ApplyOutputTemplate(
template,
platform,
roomId,
anchorName,
title,
now,
forPathSegment: true,
fileStem,
segmentSuffix: string.Empty);
var parts = raw
.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static part => SanitizeFileName(part, "untitled"))
.Where(static part => !string.IsNullOrWhiteSpace(part))
.ToArray();
return parts.Length == 0 ? now.ToString("yyyy/MM/dd") : Path.Combine(parts);
}
private static string BuildFileNameStem(
string template,
LivePlatformType platform,
string roomId,
string? anchorName,
string? title,
DateTimeOffset now,
string segmentSuffix)
{
var raw = ApplyOutputTemplate(
template,
platform,
roomId,
anchorName,
title,
now,
forPathSegment: false,
fileStem: string.Empty,
segmentSuffix);
return SanitizeFileName(raw, $"{now:yyyyMMdd_HHmmss}_{roomId}");
}
private static string EnsureSegmentSuffixTemplate(string template, RecordSaveMode saveMode)
{
var effectiveTemplate = string.IsNullOrWhiteSpace(template)
? "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"
: template.Trim();
if (saveMode != RecordSaveMode.Segmented)
{
return effectiveTemplate;
}
if (effectiveTemplate.Contains("{segmentSuffix}", StringComparison.OrdinalIgnoreCase) ||
effectiveTemplate.Contains("%05d", StringComparison.OrdinalIgnoreCase))
{
return effectiveTemplate;
}
return $"{effectiveTemplate}{{segmentSuffix}}";
}
private static string ApplyOutputTemplate(
string template,
LivePlatformType platform,
string roomId,
string? anchorName,
string? title,
DateTimeOffset now,
bool forPathSegment,
string fileStem,
string segmentSuffix)
{
var effectiveTemplate = string.IsNullOrWhiteSpace(template)
? (forPathSegment ? "{platform}/{yyyy}/{MM}/{dd}/{anchor}" : "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}")
: template;
var tokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["platform"] = platform.ToString(),
["roomId"] = roomId,
["anchor"] = NormalizeTokenValue(anchorName, "unknown-anchor"),
["title"] = NormalizeTokenValue(title, "untitled"),
["fileStem"] = fileStem,
["segmentSuffix"] = segmentSuffix,
["yyyy"] = now.ToString("yyyy"),
["MM"] = now.ToString("MM"),
["dd"] = now.ToString("dd"),
["HH"] = now.ToString("HH"),
["mm"] = now.ToString("mm"),
["ss"] = now.ToString("ss"),
["yyyyMMdd"] = now.ToString("yyyyMMdd"),
["HHmmss"] = now.ToString("HHmmss"),
["date"] = now.ToString("yyyyMMdd"),
["time"] = now.ToString("HHmmss")
};
return Regex.Replace(
effectiveTemplate,
"{(?<name>[a-zA-Z0-9]+)}",
match =>
{
var name = match.Groups["name"].Value;
return tokens.TryGetValue(name, out var value) ? value : string.Empty;
});
}
private static string NormalizeTokenValue(string? value, string fallback) =>
string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
private static string SanitizeFileName(string? value, string fallback)
{
var candidate = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = new string(candidate.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
sanitized = sanitized.Trim('.', ' ');
if (string.IsNullOrWhiteSpace(sanitized))
{
sanitized = fallback;
}
return sanitized.Length <= 96 ? sanitized : sanitized[..96].Trim();
}
internal static void TryDeleteRecordOutput(
RecordTask recordTask,
List<string> warnings,
List<string> deletedFilePaths,
List<string> deletedDanmakuPaths)
{
var outputPath = recordTask.Result?.FilePath ?? recordTask.OutputFilePath;
if (!string.IsNullOrWhiteSpace(outputPath))
{
TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}");
}
var danmakuPath = recordTask.Result?.DanmakuFilePath;
if (!string.IsNullOrWhiteSpace(danmakuPath))
{
TryDeletePath(danmakuPath, warnings, deletedDanmakuPaths, $"danmaku for task {recordTask.Id}");
}
}
private static void TryDeletePath(
string path,
List<string> warnings,
List<string> deletedPaths,
string label)
{
var absolutePath = Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
try
{
if (IsUnsafeDeletionTarget(absolutePath))
{
warnings.Add($"Skipped deleting suspicious path: {absolutePath}");
return;
}
if (File.Exists(absolutePath))
{
File.Delete(absolutePath);
deletedPaths.Add(absolutePath);
return;
}
if (Directory.Exists(absolutePath))
{
Directory.Delete(absolutePath, true);
deletedPaths.Add(absolutePath);
return;
}
warnings.Add($"Path not found for {label}: {absolutePath}");
}
catch (Exception ex)
{
warnings.Add($"Failed to delete {label}: {ex.Message}");
}
}
private static bool IsUnsafeDeletionTarget(string absolutePath)
{
var normalized = absolutePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var root = Path.GetPathRoot(normalized)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.IsNullOrWhiteSpace(normalized) ||
normalized.Length < 4 ||
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,285 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
namespace LiveRecorder.Application.Services;
public sealed class RecordSessionService
{
private static readonly TimeSpan GracefulDeleteTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan ForcedKillTimeout = TimeSpan.FromSeconds(8);
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordResultRepository _recordResultRepository;
private readonly ISystemLogRepository _systemLogRepository;
private readonly IFfmpegService _ffmpegService;
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
private readonly ISystemLogService _systemLogService;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IUnitOfWork _unitOfWork;
public RecordSessionService(
IRecordSessionRepository recordSessionRepository,
IRecordTaskRepository recordTaskRepository,
IRecordResultRepository recordResultRepository,
ISystemLogRepository systemLogRepository,
IFfmpegService ffmpegService,
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
ISystemLogService systemLogService,
IServiceScopeFactory serviceScopeFactory,
IUnitOfWork unitOfWork)
{
_recordSessionRepository = recordSessionRepository;
_recordTaskRepository = recordTaskRepository;
_recordResultRepository = recordResultRepository;
_systemLogRepository = systemLogRepository;
_ffmpegService = ffmpegService;
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
_systemLogService = systemLogService;
_serviceScopeFactory = serviceScopeFactory;
_unitOfWork = unitOfWork;
}
public async Task<IReadOnlyList<RecordSessionDto>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default)
{
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
return sessions
.OrderByDescending(static item => item.CreatedAt)
.Select(RecordModelMapper.MapSession)
.ToList();
}
public async Task<RecordSessionDetailDto?> GetDetailAsync(Guid id, CancellationToken cancellationToken = default)
{
var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken);
if (session is null)
{
return null;
}
if (IsActiveStatus(session.Status))
{
await _ffmpegService.TryReconcileInactiveSessionAsync(id, cancellationToken);
session = await LoadSessionSnapshotAsync(id, cancellationToken);
if (session is null)
{
return null;
}
}
var logs = await _systemLogService.ListAsync(recordSessionId: id, take: 500, cancellationToken: cancellationToken);
return new RecordSessionDetailDto
{
Session = RecordModelMapper.MapSession(session),
Logs = logs
};
}
public async Task<RecordSessionDto> StopAsync(Guid id, CancellationToken cancellationToken = default)
{
var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Recording session was not found.");
if (!IsActiveStatus(session.Status))
{
throw new InvalidOperationException("The recording session is not running.");
}
session.MarkStopping(DateTimeOffset.UtcNow);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _ffmpegService.StopAsync(id, cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"RecordSession",
"Stop signal sent to the recording session.",
liveRoomId: session.LiveRoomId,
recordSessionId: session.Id,
cancellationToken: cancellationToken);
return RecordModelMapper.MapSession(session);
}
public async Task<DeleteCompletedRecordTasksResultDto> DeleteAsync(
DeleteRecordSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sessionIds = request.SessionIds
.Where(static item => item != Guid.Empty)
.Distinct()
.ToArray();
if (sessionIds.Length == 0)
{
return RecordService.CreateEmptyDeleteResult();
}
var warnings = new List<string>();
var deletedSessionIds = new List<Guid>();
var deletedTaskIds = new List<Guid>();
var deletedFilePaths = new List<string>();
var deletedDanmakuPaths = new List<string>();
foreach (var sessionId in sessionIds)
{
var session = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken);
if (session is null)
{
warnings.Add($"Session {sessionId} was not found.");
continue;
}
if (IsActiveStatus(session.Status))
{
var stopped = await _ffmpegService.StopAndWaitAsync(
session.Id,
markAsCompletedOnExit: false,
GracefulDeleteTimeout,
cancellationToken);
if (!stopped)
{
warnings.Add($"Session {session.Id} did not stop gracefully in time. Force killing the ffmpeg process.");
var killed = await _ffmpegService.KillAndWaitAsync(session.Id, ForcedKillTimeout, cancellationToken);
if (!killed)
{
warnings.Add($"Session {session.Id} is still shutting down. It was not deleted.");
continue;
}
}
await _ffmpegService.TryReconcileInactiveSessionAsync(session.Id, cancellationToken);
var sessionSnapshot = await LoadSessionSnapshotAsync(sessionId, cancellationToken);
if (sessionSnapshot is null)
{
deletedSessionIds.Add(sessionId);
continue;
}
session = sessionSnapshot;
}
if (_ffmpegService.IsRunning(session.Id) || IsActiveStatus(session.Status))
{
warnings.Add($"Session {session.Id} is still active and could not be deleted.");
continue;
}
session = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken);
if (session is null)
{
deletedSessionIds.Add(sessionId);
continue;
}
var taskIds = session.RecordTasks
.Select(static item => item.Id)
.Distinct()
.ToArray();
if (request.DeleteFiles)
{
foreach (var recordTask in session.RecordTasks)
{
RecordService.TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths);
}
}
var relatedLogs = await ListRelatedLogsAsync(session.Id, taskIds, cancellationToken);
if (relatedLogs.Count > 0)
{
_systemLogRepository.RemoveRange(relatedLogs);
}
var results = session.RecordTasks
.Select(static item => item.Result)
.OfType<RecordResult>()
.ToArray();
if (results.Length > 0)
{
_recordResultRepository.RemoveRange(results);
}
if (session.RecordTasks.Count > 0)
{
_recordTaskRepository.RemoveRange(session.RecordTasks);
deletedTaskIds.AddRange(taskIds);
}
_recordSessionRepository.Remove(session);
await _unitOfWork.SaveChangesAsync(cancellationToken);
deletedSessionIds.Add(session.Id);
}
if (deletedSessionIds.Count > 0)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"RecordSession",
$"Deleted {deletedSessionIds.Count} recording session(s).",
detail: request.DeleteFiles
? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; tasks={deletedTaskIds.Count}"
: $"video-files=0; danmaku-files=0; tasks={deletedTaskIds.Count}",
cancellationToken: cancellationToken);
}
return new DeleteCompletedRecordTasksResultDto
{
DeletedTaskIds = deletedTaskIds,
DeletedFilePaths = deletedFilePaths,
DeletedDanmakuPaths = deletedDanmakuPaths,
DeletedSessionIds = deletedSessionIds,
Warnings = warnings
};
}
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
{
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
var activeIds = sessions
.Where(item => IsActiveStatus(item.Status))
.Select(item => item.Id)
.Distinct()
.ToArray();
foreach (var activeId in activeIds)
{
await _ffmpegService.TryReconcileInactiveSessionAsync(activeId, cancellationToken);
}
}
private static bool IsActiveStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
private async Task<IReadOnlyList<SystemLogEntry>> ListRelatedLogsAsync(
Guid recordSessionId,
IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken)
{
var bySession = await _systemLogRepository.ListByRecordSessionIdsAsync([recordSessionId], cancellationToken);
var byTask = await _systemLogRepository.ListByRecordTaskIdsAsync(taskIds, cancellationToken);
return bySession
.Concat(byTask)
.GroupBy(static item => item.Id)
.Select(static group => group.First())
.ToArray();
}
private async Task<RecordSession?> LoadSessionSnapshotAsync(Guid id, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IRecordSessionRepository>();
return await repository.GetByIdAsync(id, cancellationToken);
}
}
@@ -0,0 +1,184 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class StoppedOrphanRecordSessionCleanupService
{
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordResultRepository _recordResultRepository;
private readonly ISystemLogRepository _systemLogRepository;
private readonly ISystemLogService _systemLogService;
private readonly IUnitOfWork _unitOfWork;
public StoppedOrphanRecordSessionCleanupService(
IRecordSessionRepository recordSessionRepository,
IRecordTaskRepository recordTaskRepository,
IRecordResultRepository recordResultRepository,
ISystemLogRepository systemLogRepository,
ISystemLogService systemLogService,
IUnitOfWork unitOfWork)
{
_recordSessionRepository = recordSessionRepository;
_recordTaskRepository = recordTaskRepository;
_recordResultRepository = recordResultRepository;
_systemLogRepository = systemLogRepository;
_systemLogService = systemLogService;
_unitOfWork = unitOfWork;
}
public async Task<IReadOnlyList<Guid>> CleanupAsync(
Guid? liveRoomId = null,
bool treatMatchingLiveRoomAsMissing = false,
CancellationToken cancellationToken = default)
{
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
var candidates = sessions
.Where(item => ShouldCleanup(item, liveRoomId, treatMatchingLiveRoomAsMissing))
.Select(static item => item.Id)
.Distinct()
.ToArray();
if (candidates.Length == 0)
{
return [];
}
var cleanedSessionIds = new List<Guid>();
foreach (var sessionId in candidates)
{
var trackedSession = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken);
if (trackedSession is null || !ShouldCleanup(trackedSession, liveRoomId, treatMatchingLiveRoomAsMissing))
{
continue;
}
var taskIds = trackedSession.RecordTasks
.Select(static item => item.Id)
.Distinct()
.ToArray();
var results = trackedSession.RecordTasks
.Select(static item => item.Result)
.OfType<RecordResult>()
.ToArray();
var relatedLogs = await ListRelatedLogsAsync(trackedSession.Id, taskIds, cancellationToken);
var taskCount = trackedSession.RecordTasks.Count;
var relatedLiveRoomId = trackedSession.LiveRoomId;
if (relatedLogs.Count > 0)
{
_systemLogRepository.RemoveRange(relatedLogs);
}
if (results.Length > 0)
{
_recordResultRepository.RemoveRange(results);
}
if (trackedSession.RecordTasks.Count > 0)
{
_recordTaskRepository.RemoveRange(trackedSession.RecordTasks);
}
_recordSessionRepository.Remove(trackedSession);
await _unitOfWork.SaveChangesAsync(cancellationToken);
cleanedSessionIds.Add(trackedSession.Id);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"RecordSession",
"Automatically cleaned a stopped orphan session without valid video segments.",
detail: $"sessionId={trackedSession.Id}; taskCount={taskCount}; liveRoomId={relatedLiveRoomId}",
cancellationToken: cancellationToken);
}
return cleanedSessionIds;
}
private async Task<IReadOnlyList<SystemLogEntry>> ListRelatedLogsAsync(
Guid recordSessionId,
IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken)
{
var bySession = await _systemLogRepository.ListByRecordSessionIdsAsync([recordSessionId], cancellationToken);
var byTask = await _systemLogRepository.ListByRecordTaskIdsAsync(taskIds, cancellationToken);
return bySession
.Concat(byTask)
.GroupBy(static item => item.Id)
.Select(static group => group.First())
.ToArray();
}
private static bool ShouldCleanup(
RecordSession session,
Guid? liveRoomId,
bool treatMatchingLiveRoomAsMissing)
{
if (session.Status != RecordSessionStatus.Stopped)
{
return false;
}
var liveRoomMissing = session.LiveRoom is null ||
(treatMatchingLiveRoomAsMissing &&
liveRoomId.HasValue &&
session.LiveRoomId == liveRoomId.Value);
if (!liveRoomMissing)
{
return false;
}
return !HasAnyValidVideoSegments(session.RecordTasks);
}
private static bool HasAnyValidVideoSegments(IEnumerable<RecordTask> tasks)
{
foreach (var task in tasks)
{
if (!TryResolveVideoPath(task, out var absolutePath))
{
continue;
}
try
{
var fileInfo = new FileInfo(absolutePath);
if (fileInfo.Exists && fileInfo.Length > 0)
{
return true;
}
}
catch
{
// Ignore malformed or inaccessible paths and continue scanning the session.
}
}
return false;
}
private static bool TryResolveVideoPath(RecordTask task, out string absolutePath)
{
var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath)
? task.Result!.FilePath
: task.OutputFilePath;
if (string.IsNullOrWhiteSpace(candidatePath) || candidatePath.Contains('%'))
{
absolutePath = string.Empty;
return false;
}
absolutePath = Path.IsPathRooted(candidatePath)
? candidatePath
: Path.GetFullPath(candidatePath, AppContext.BaseDirectory);
return true;
}
}
@@ -0,0 +1,65 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
namespace LiveRecorder.Application.Services;
public sealed class SystemLogService : ISystemLogService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ISystemLogRepository _systemLogRepository;
public SystemLogService(
IServiceScopeFactory serviceScopeFactory,
ISystemLogRepository systemLogRepository)
{
_serviceScopeFactory = serviceScopeFactory;
_systemLogRepository = systemLogRepository;
}
public async Task WriteAsync(
SystemLogLevel level,
string category,
string message,
string? detail = null,
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
CancellationToken cancellationToken = default)
{
var entry = new SystemLogEntry(level, category, message, detail, liveRoomId, recordSessionId, recordTaskId, DateTimeOffset.UtcNow);
using var scope = _serviceScopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<ISystemLogRepository>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
await repository.AddAsync(entry, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<SystemLogDto>> ListAsync(
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
int take = 200,
CancellationToken cancellationToken = default)
{
var entries = await _systemLogRepository.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken);
return entries
.Select(static item => new SystemLogDto
{
Id = item.Id,
Level = item.Level,
Category = item.Category,
Message = item.Message,
Detail = item.Detail,
LiveRoomId = item.LiveRoomId,
RecordSessionId = item.RecordSessionId,
RecordTaskId = item.RecordTaskId,
CreatedAt = item.CreatedAt
})
.ToList();
}
}
@@ -0,0 +1,226 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class SystemSettingsService : ISystemSettingsService
{
private const string FfmpegPathKey = "ffmpeg.path";
private const string OutputRootKey = "recording.output_root";
private const string OutputDirectoryTemplateKey = "recording.output_directory_template";
private const string OutputFileNameTemplateKey = "recording.output_file_name_template";
private const string DefaultQualityKey = "recording.default_quality";
private const string DefaultOutputFormatKey = "recording.default_output_format";
private const string SaveModeKey = "recording.save_mode";
private const string RecordingTemplateKey = "recording.template";
private const string SegmentDurationMinutesKey = "recording.segment_duration_minutes";
private const string EnableReconnectKey = "recording.enable_auto_reconnect";
private const string ReconnectDelayMaxSecondsKey = "recording.reconnect_delay_max_seconds";
private const string ReadWriteTimeoutMillisecondsKey = "recording.read_write_timeout_milliseconds";
private const string EnableDanmakuRecordingKey = "recording.enable_danmaku_recording";
private const string DanmakuIncludeNonChatEventsKey = "recording.danmaku_include_non_chat_events";
private const string DanmakuMinPollIntervalMillisecondsKey = "recording.danmaku_min_poll_interval_milliseconds";
private const string DanmakuRetryDelayMaxSecondsKey = "recording.danmaku_retry_delay_max_seconds";
private const string EnableBackgroundPollingKey = "scheduler.enable_background_polling";
private const string AutoStartRecordingOnLiveKey = "scheduler.auto_start_recording_on_live";
private const string PollingIntervalSecondsKey = "scheduler.polling_interval_seconds";
private const string EnableEmailNotificationKey = "notification.email.enabled";
private const string EmailSmtpHostKey = "notification.email.smtp_host";
private const string EmailSmtpPortKey = "notification.email.smtp_port";
private const string EmailUseSslKey = "notification.email.use_ssl";
private const string EmailUsernameKey = "notification.email.username";
private const string EmailPasswordKey = "notification.email.password";
private const string EmailFromAddressKey = "notification.email.from_address";
private const string EmailFromDisplayNameKey = "notification.email.from_display_name";
private const string EmailToAddressesKey = "notification.email.to_addresses";
private const string NotifyOnLiveStartedKey = "notification.email.notify_live_started";
private const string NotifyOnExceptionKey = "notification.email.notify_exception";
private const string EmailLiveStartedSubjectTemplateKey = "notification.email.live_started.subject_template";
private const string EmailLiveStartedBodyTemplateHtmlKey = "notification.email.live_started.body_template_html";
private const string EmailExceptionSubjectTemplateKey = "notification.email.exception.subject_template";
private const string EmailExceptionBodyTemplateHtmlKey = "notification.email.exception.body_template_html";
private const string DouyinUserAgentKey = "douyin.user_agent";
private const string DouyinRefererKey = "douyin.referer";
private const string DouyinCookieKey = "douyin.cookie";
private readonly IAppSettingRepository _appSettingRepository;
private readonly IUnitOfWork _unitOfWork;
public SystemSettingsService(IAppSettingRepository appSettingRepository, IUnitOfWork unitOfWork)
{
_appSettingRepository = appSettingRepository;
_unitOfWork = unitOfWork;
}
public async Task<SystemSettingsDto> GetAsync(CancellationToken cancellationToken = default)
{
var settings = await _appSettingRepository.ListAsync(cancellationToken);
var lookup = settings.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.OrdinalIgnoreCase);
return new SystemSettingsDto
{
FfmpegPath = GetValue(lookup, FfmpegPathKey, "ffmpeg"),
OutputRoot = GetValue(lookup, OutputRootKey, "records"),
OutputDirectoryTemplate = GetValue(lookup, OutputDirectoryTemplateKey, "{platform}/{yyyy}/{MM}/{dd}/{anchor}"),
OutputFileNameTemplate = GetValue(lookup, OutputFileNameTemplateKey, "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"),
DefaultQuality = GetValue(lookup, DefaultQualityKey, "origin"),
DefaultOutputFormat = Enum.TryParse(GetValue(lookup, DefaultOutputFormatKey, "Mp4"), true, out RecordOutputFormat outputFormat)
? outputFormat
: RecordOutputFormat.Mp4,
SaveMode = Enum.TryParse(GetValue(lookup, SaveModeKey, "SingleFile"), true, out RecordSaveMode saveMode)
? saveMode
: RecordSaveMode.SingleFile,
RecordingTemplate = Enum.TryParse(GetValue(lookup, RecordingTemplateKey, "StreamCopy"), true, out RecordingTemplateType recordingTemplate)
? recordingTemplate
: RecordingTemplateType.StreamCopy,
SegmentDurationMinutes = GetIntValue(lookup, SegmentDurationMinutesKey, 30, 1, 720),
EnableAutoReconnect = bool.TryParse(GetValue(lookup, EnableReconnectKey, "true"), out var enableReconnect) && enableReconnect,
ReconnectDelayMaxSeconds = GetIntValue(lookup, ReconnectDelayMaxSecondsKey, 5, 1, 300),
ReadWriteTimeoutMilliseconds = GetIntValue(lookup, ReadWriteTimeoutMillisecondsKey, 15000000, 1000, 60000000),
EnableDanmakuRecording = bool.TryParse(GetValue(lookup, EnableDanmakuRecordingKey, "true"), out var enableDanmakuRecording) && enableDanmakuRecording,
DanmakuIncludeNonChatEvents = bool.TryParse(GetValue(lookup, DanmakuIncludeNonChatEventsKey, "true"), out var danmakuIncludeNonChatEvents) && danmakuIncludeNonChatEvents,
DanmakuMinPollIntervalMilliseconds = GetIntValue(lookup, DanmakuMinPollIntervalMillisecondsKey, 1000, 100, 60000),
DanmakuRetryDelayMaxSeconds = GetIntValue(lookup, DanmakuRetryDelayMaxSecondsKey, 15, 1, 300),
EnableBackgroundPolling = bool.TryParse(GetValue(lookup, EnableBackgroundPollingKey, "true"), out var enableBackgroundPolling) && enableBackgroundPolling,
AutoStartRecordingOnLive = bool.TryParse(GetValue(lookup, AutoStartRecordingOnLiveKey, "true"), out var autoStartRecordingOnLive) && autoStartRecordingOnLive,
PollingIntervalSeconds = GetIntValue(lookup, PollingIntervalSecondsKey, 60, 10, 3600),
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535),
EmailUseSsl = bool.TryParse(GetValue(lookup, EmailUseSslKey, "true"), out var emailUseSsl) && emailUseSsl,
EmailUsername = GetValue(lookup, EmailUsernameKey, string.Empty),
EmailPassword = GetValue(lookup, EmailPasswordKey, string.Empty),
EmailFromAddress = GetValue(lookup, EmailFromAddressKey, string.Empty),
EmailFromDisplayName = GetValue(lookup, EmailFromDisplayNameKey, "Live Recorder"),
EmailToAddresses = GetValue(lookup, EmailToAddressesKey, string.Empty),
NotifyOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyOnLiveStartedKey, "true"), out var notifyOnLiveStarted) && notifyOnLiveStarted,
NotifyOnException = bool.TryParse(GetValue(lookup, NotifyOnExceptionKey, "true"), out var notifyOnException) && notifyOnException,
EmailLiveStartedSubjectTemplate = GetValue(lookup, EmailLiveStartedSubjectTemplateKey, "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"),
EmailLiveStartedBodyTemplateHtml = GetValue(
lookup,
EmailLiveStartedBodyTemplateHtmlKey,
"""
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
<p>The monitored live room is now online.</p>
<ul>
<li><strong>Platform:</strong> {{platform}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
</div>
"""),
EmailExceptionSubjectTemplate = GetValue(lookup, EmailExceptionSubjectTemplateKey, "[{{appName}}] Exception: {{source}}"),
EmailExceptionBodyTemplateHtml = GetValue(
lookup,
EmailExceptionBodyTemplateHtmlKey,
"""
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
<p>{{summary}}</p>
<ul>
<li><strong>Source:</strong> {{source}}</li>
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>
"""),
DouyinUserAgent = GetValue(
lookup,
DouyinUserAgentKey,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"),
DouyinReferer = GetValue(lookup, DouyinRefererKey, "https://live.douyin.com/"),
DouyinCookie = GetValue(lookup, DouyinCookieKey, string.Empty)
};
}
public async Task<SystemSettingsDto> UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var now = DateTimeOffset.UtcNow;
await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken);
await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken);
await UpsertAsync(OutputDirectoryTemplateKey, request.OutputDirectoryTemplate.Trim(), now, cancellationToken);
await UpsertAsync(OutputFileNameTemplateKey, request.OutputFileNameTemplate.Trim(), now, cancellationToken);
await UpsertAsync(DefaultQualityKey, request.DefaultQuality.Trim(), now, cancellationToken);
await UpsertAsync(DefaultOutputFormatKey, request.DefaultOutputFormat.ToString(), now, cancellationToken);
await UpsertAsync(SaveModeKey, request.SaveMode.ToString(), now, cancellationToken);
await UpsertAsync(RecordingTemplateKey, request.RecordingTemplate.ToString(), now, cancellationToken);
await UpsertAsync(SegmentDurationMinutesKey, request.SegmentDurationMinutes.ToString(), now, cancellationToken);
await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken);
await UpsertAsync(ReconnectDelayMaxSecondsKey, request.ReconnectDelayMaxSeconds.ToString(), now, cancellationToken);
await UpsertAsync(ReadWriteTimeoutMillisecondsKey, request.ReadWriteTimeoutMilliseconds.ToString(), now, cancellationToken);
await UpsertAsync(EnableDanmakuRecordingKey, request.EnableDanmakuRecording.ToString(), now, cancellationToken);
await UpsertAsync(DanmakuIncludeNonChatEventsKey, request.DanmakuIncludeNonChatEvents.ToString(), now, cancellationToken);
await UpsertAsync(DanmakuMinPollIntervalMillisecondsKey, request.DanmakuMinPollIntervalMilliseconds.ToString(), now, cancellationToken);
await UpsertAsync(DanmakuRetryDelayMaxSecondsKey, request.DanmakuRetryDelayMaxSeconds.ToString(), now, cancellationToken);
await UpsertAsync(EnableBackgroundPollingKey, request.EnableBackgroundPolling.ToString(), now, cancellationToken);
await UpsertAsync(AutoStartRecordingOnLiveKey, request.AutoStartRecordingOnLive.ToString(), now, cancellationToken);
await UpsertAsync(PollingIntervalSecondsKey, request.PollingIntervalSeconds.ToString(), now, cancellationToken);
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken);
await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken);
await UpsertAsync(EmailUseSslKey, request.EmailUseSsl.ToString(), now, cancellationToken);
await UpsertAsync(EmailUsernameKey, request.EmailUsername.Trim(), now, cancellationToken);
await UpsertAsync(EmailPasswordKey, request.EmailPassword, now, cancellationToken);
await UpsertAsync(EmailFromAddressKey, request.EmailFromAddress.Trim(), now, cancellationToken);
await UpsertAsync(EmailFromDisplayNameKey, request.EmailFromDisplayName.Trim(), now, cancellationToken);
await UpsertAsync(EmailToAddressesKey, request.EmailToAddresses.Trim(), now, cancellationToken);
await UpsertAsync(NotifyOnLiveStartedKey, request.NotifyOnLiveStarted.ToString(), now, cancellationToken);
await UpsertAsync(NotifyOnExceptionKey, request.NotifyOnException.ToString(), now, cancellationToken);
await UpsertAsync(EmailLiveStartedSubjectTemplateKey, request.EmailLiveStartedSubjectTemplate.Trim(), now, cancellationToken);
await UpsertAsync(EmailLiveStartedBodyTemplateHtmlKey, request.EmailLiveStartedBodyTemplateHtml.Trim(), now, cancellationToken);
await UpsertAsync(EmailExceptionSubjectTemplateKey, request.EmailExceptionSubjectTemplate.Trim(), now, cancellationToken);
await UpsertAsync(EmailExceptionBodyTemplateHtmlKey, request.EmailExceptionBodyTemplateHtml.Trim(), now, cancellationToken);
await UpsertAsync(DouyinUserAgentKey, request.DouyinUserAgent.Trim(), now, cancellationToken);
await UpsertAsync(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken);
await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return await GetAsync(cancellationToken);
}
private static string GetValue(IReadOnlyDictionary<string, string> lookup, string key, string fallback) =>
lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback;
private static int GetIntValue(
IReadOnlyDictionary<string, string> lookup,
string key,
int fallback,
int minimum,
int maximum)
{
var raw = GetValue(lookup, key, fallback.ToString());
if (!int.TryParse(raw, out var parsedValue))
{
return fallback;
}
return Math.Clamp(parsedValue, minimum, maximum);
}
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
if (existing is null)
{
await _appSettingRepository.AddAsync(new AppSetting(key, value, updatedAt), cancellationToken);
return;
}
existing.Update(value, updatedAt);
_appSettingRepository.Update(existing);
}
}