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,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);
}
}